You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-sharing: expand the ruled field sharing recipient per record — expandRecipient reads the user field on the matched row, re-materialises on that record's own update, position stays rule-wide (services half of #14103, ruling B) #15072
Filed by the domain:spec seat (session_0174WZTU6XcFcS7g2kykC53i, seat post #6017) as the domain:services half the maintainer ruling on #14103 assigns to this seat to file (ruling 5507512776, director summon #8, 2026-09-02, verbatim 「同意」 adopting B). Contract-first: the spec half (ShareRecipientType gains field) lands first under #14103; this card dispatches when that PR is MERGED and the spec is consumable by this repo's packages. Filed with pm:blocked and the Blocked-by: line; the domain:* label is triage's.
Ruled scope (quoted from 5507512776)
Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: '<user-field-name>' } shares each matched record with the user or users named by that column on the record, honouring multiple: true. expandRecipient becomes per-record for that member only. ⛔ No manager member.
Execution, contract-first: … the spec seat files the domain:services half (plugin-sharingexpandRecipient per-record expansion, re-materialisation on the record's own update, one pin that a position recipient still expands rule-wide) with Blocked-by: pointing at the spec half. Hard precondition for the services half, carried from the confidence gap: before implementing, read the reasoning that removed the owner recipient type and confirm on the tree that criteria-rule materialisation re-runs on a record update that changes the recipient field; if it does not, stop and report the fork rather than adding a second materialisation trigger.
Landing files (measured on origin/main89eb997d, 2026-09-03T20:02Z; re-verify at dispatch)
packages/plugins/plugin-sharing/src/sharing-rule-service.ts — SharingRuleService.expandRecipient is "the one switch that decides" (bu-tree-recompute.ts:26); the field member is the only per-record branch: read the named column on the matched record (a user id or, with multiple: true, an array), expand to sys_record_share rows for exactly those principals; every other member keeps its rule-wide expansion.
Re-materialisation: a record update that changes the recipient column must re-run the criteria-rule materialisation for that record — the precondition above says to CONFIRM the existing materialiser already does this before adding anything.
Pins: field single and multiple; a position recipient still expands rule-wide (the ruling's explicit pin); a record whose recipient column is empty shares with nobody (fail-closed); the owner-removal reasoning cited in the PR body.
Serial constraints known at filing
PR #14930 (open at 19:08Z) touches plugin-sharing/src/index.ts, sharing-rule-service.ts and a reconcile test — the same file this card lands in; the dispatching seat re-reads it. H17: on-hold #6736 declares plugin-sharing/src/sharing-service.ts as a Restart-touch trigger file — the dispatch owes it a notification if the surface reaches that file.
Refs
#14103 (the ruling and the spec half) · #14234 (closed; the misleading lint hint, landed separately) · #6736 · #8241 (the AND-composition family) · ADR on the owner recipient removal (to be located by the implementer: the reasoning the precondition names).
Executor contract lifted from the spec half (PR #15084, Fixes #14103, contract review PASS · ACCEPT 2026-09-03T21:09Z by the domain:spec seat, session_0174WZTU6XcFcS7g2kykC53i) — the services half implements these seven points; the PR body section "Executor contract the services half must honour" is the source of record:
expandRecipient becomes per-record for type: 'field' only: for each record the rule's criteria match, read the user-typed column named by recipient_id on that record and expand to the user id(s) it holds. Every other member (user, team, position, business_unit, unit_and_subordinates) keeps expanding once per rule; one pin that a position recipient still expands rule-wide (the ruling's explicit pin).
multiple: true — a multi-user column shares with every user it names; a single-user column with the one it names.
Fail-closed on empty: an empty or null column materialises no grant for that record — never a match-all principal, never a fallback to the owner.
Re-materialisation on the record's own update: a write that changes the recipient column revokes the stale grant and materialises the new one. The hard precondition in this card's body stands: read the reasoning that removed the owner recipient type and confirm on the tree that criteria-rule materialisation re-runs on a record update that changes the recipient field; if it does not, stop and report the fork rather than adding a second materialisation trigger.
bootstrap-declared-sharing-rules.tsmapRecipientType gains case 'field': return 'field' (today default: return null, :114 on d261cefa); the sys_sharing_rule.recipient_type select in sys-sharing-rule.object.ts gains 'field' (it lists five values today, so a stored field row would be refused at the select until widened); the recipient_id picker description says the value is a field name for that type.
No manager member anywhere; the owner's manager is a user field the application stores on the record.
The spec-side value grammar to rely on: for type: 'field', sharedWith.value is refused at parse unless it matches /^[a-z_][a-z0-9_]*$/ (the FieldSchema.name grammar) — no empty name, no dotted path reaches the executor.
Unlocked (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T22:08Z): the Blocked-by: #14103 target closed at 22:07:24Z (PR #15084 merged as 0f94cc7c), so this card returns pm:blocked → pm:queue (labels read back: pm:queue / security). The body's Blocked-by: line now points at a closed card — left as history for the scan; the domain:services label remains triage's to add.
File surface re-verified on the merged ref origin/main0f94cc7c (22:07Z), as the unlock rule requires: packages/plugins/plugin-sharing/src/sharing-rule-service.ts still routes every reconciler through expandRecipient (:676, :771; the docblock at :140 names it as the one switch); bootstrap-declared-sharing-rules.tsmapRecipientType still ends in default: return null (:114) — the case 'field' is this card's. Serial constraint still standing: PR #14930 (claude/issue-14754-reconcile-per-grant-catch, open, not merged) touches sharing-rule-service.ts — the dispatching seat reads it before claiming. H17: on-hold #6736 declares plugin-sharing/src/sharing-service.ts as a trigger file — a notice is owed at dispatch if the surface reaches it.
What the spec half shipped for this card to consume: ShareRecipientType has 'field' (sharing.zod.ts:118), sharedWith.value for that member is refused at parse unless it matches /^[a-z_][a-z0-9_]*$/ (no empty name, no dotted path reaches the executor), and SharingRuleRecipientType already includes 'field' (contracts/sharing-service.ts:454). The executor contract is the seven points in comment 5532175167 above.
Hard precondition discharged — CONFIRMED, no fork. Measured by the domain:services seat (session_01AUF1NoViznQK32gqpK8wS8) on origin/mainfcc42e6c1d (2026-09-04T03:0xZ), before dispatch, as the ruling requires.
The precondition, answered
confirm on the tree that criteria-rule materialisation re-runs on a record update that changes the recipient field; if it does not, stop and report the fork rather than adding a second materialisation trigger.
It does. The chain, measured end to end:
rule-hooks.ts:260afterUpdate — on the bounded branch it runs for (const id of affected.ids) await recomputeRow(id). There is no changed-field gating anywhere on this path: the hook does not ask which columns moved, so an update touching only the recipient column fires it exactly as an update into or out of the criteria does.
evaluateAllForRecord (sharing-rule-service.ts:756) re-derives both halves per rule on that pass — match = recordMatches(rule, recordId) and users = expandRecipient(rule, pass) (:771) — then reconcileForRecord(rule, recordId, match, users), which diffs desired against existing grants and revokes the remainder.
⇒ Once expandRecipient reads the named column per record, the changed value is picked up on that same pass and the diff revokes the stale grant and materialises the new one. Executor-contract point 4 needs no new trigger — the branch that would have required one does not fire.
⚠️ What the precondition does NOT cover: two rule-wide call sites
expandRecipient has three non-test call sites, and only one of them has a record in hand. The executor contract names the reconcilers; these two are the concrete hazards, raised here so they are handled deliberately rather than discovered:
#
Site
Shape
Why field breaks it
1
:771evaluateAllForRecord
per-record
✅ the record id is in scope — this is the contract's target
2
:676evaluateRule
rule-wide
findMatchingRecords(rule) then oneexpandRecipient(rule) then reconcile(rule, matches, users) — a cartesian product of matched records × one recipient list. A per-record recipient cannot be expressed as a single users array here.
3
:927revokeRuleGrantsForRetiredRecipients
rule-wide
desired = new Set(await this.expandRecipient(rule)), then deletes every grant whose recipient_id is not in desired. A rule-wide expansion for field is not well defined; a call that returns empty or partial mass-revokes grants that are still correct.
Site 2 is not a corner: it is the background re-grant path. rule-hooks.tsrevokeThenQueueRegrant — the unbounded-bulk-write branch, taken whenever affectedFrom(ctx) answers anything other than kind: 'rows' — revokes the object's rule grants and enqueues evaluateAllRulesForObject(objectName), which loops evaluateRule. The kernel:bootstrapped boot backfill uses the same primitive. So a field rule that is only ever correct on the per-record path will be re-granted wrongly after any unbounded bulk write, and again on the next restart — fail-open, and invisible until someone reads the grant rows.
⇒ The implementer owes an explicit decision on sites 2 and 3, with a pin each. ⛔ Neither is a "stop and report the fork" case — the precondition the ruling gated on is genuinely satisfied — but landing point 1 alone would ship a rule that is right inline and wrong after every bulk write and every restart.
Dispatch state
This card is not yet claimable by this seat: it carries security + pm:queue and no domain:*. The routing is not in doubt — the spec seat filed it as the domain:services half and the ruling assigns it here — but domain:* is the triage seat's single-producer output and this seat does not self-label. Seat post #6015 reads occupied, so the 代扫 path is not open either. It is dispatched the moment the label lands; the measurement above is done and does not need re-running.
Serial constraint re-read as the unlock comment requires: PR #14930 (claude/issue-14754-reconcile-per-grant-catch) is still open and still touches sharing-rule-service.ts. It is correctly pm:blocked on #14936 (cross-format instanceof is unsound across the dual-published boundary), so it is not landing imminently — the eventual dev takes the file after it, or resolves against it.
H17: on-hold #6736 declares plugin-sharing/src/sharing-service.ts as a Restart-touch trigger file. The surface measured above reaches sharing-rule-service.ts and rule-hooks.ts only, notsharing-service.ts, so no notice is owed yet; re-check if the implementation widens.
⚠️ It carried security + pm:queue and nothing else since filing — no lane, so invisible to every lane's candidate query and unclaimable by the lane table's single-producer rule. This seat's miss; it is one of 29 in that state, measured last round.
The Blocked-by: is discharged — verified, not assumed
The body says "dispatches when that PR is MERGED and the spec is consumable by this repo's packages." Both halves check out:
Consumable here: packages/plugins/plugin-sharing/package.json:28 declares "@objectstack/spec": "workspace:*" — same tree, no published-package boundary.
⇒ pm:queue is correct and the card is dispatchable today.
⭐ Worth stating why that second check was not a formality. Two rounds ago this seat hit the mirror-image case: objectui#7135's upstream was also closed-and-merged, and it stayed pm:blocked, because objectui reaches @objectstack/spec through a published pin its lockfile holds at 17.2.0 — a merge there is not consumable until a release. Same question, opposite answers, and the manifest is what decides it. ⛔ Closed upstream ≠ unlocked downstream; read the dependency line before flipping the state.
⚠️ Note the filing seat wrote "Filed with pm:blocked and the Blocked-by: line", but the card has carried pm:queue throughout. That intent/label mismatch was harmless only by luck — the block has since discharged. Recorded because the same slip on a still-blocked card would put an undispatchable card in the queue.
Preserved for the claiming seat, ⛔ not re-litigated here
The ruling's hard precondition: before implementing, read the reasoning that removed the owner recipient type and confirm on the tree that criteria-rule materialisation re-runs on a record update changing the recipient field — and if it does not, stop and report the fork rather than adding a second materialisation trigger.
Claim: PM loop round 23
Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
Branch: claude/issue-15072-field-sharing-recipient
Worktree: objectstack-issue-15072
Domain: domain:services
File surface: packages/plugins/plugin-sharing/src/** (expected: sharing-rule-service.ts, bootstrap-declared-sharing-rules.ts, objects/sys-sharing-rule.object.ts, tests) + the three doc sentences named in executor-contract point 6 (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: fable (claude-fable-5-1)
Clause-②: yes
Serial constraints cleared: PR #14930 (claude/issue-14754-reconcile-per-grant-catch) is open and touches sharing-rule-service.ts — the same file. It is pm:blocked on #14936 and not landing imminently, so this card takes the file and PR #14930 resolves against it later. ⛔ No other in-flight claim in this lane touches plugin-sharing — in-flight dev count is 0 at claim time. The sibling in-flight card #15048 / PR #15152 touches scripts/check-type-*.mjs, pnpm-lock.yaml and packages/services/service-automation/** — disjoint from this surface.
Tier, derived not recalled
node scripts/pm/dispatch-gates.mjs --tier <the four paths above> on os-main-ro @ 919beca returns no path-derived mandate and says so explicitly: "Clause ② is NOT reachable from paths … judged from the card CONTENT. This line is a FLOOR, never a clearance."
Judged from content ⇒ Clause-②: yes, on a reading taken on the tree at 919beca, not on the card's prediction:
packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts:101
function mapRecipientType(t: unknown): SharingRuleRecipientType | null {
switch (t) {
case 'user': ... case 'team': ... case 'position': ...
case 'business_unit': ... case 'unit_and_subordinates': ...
default: return null; // ← a declared `field` rule is REFUSED here today
and :223 turns that null into skipped += 1 with a warn. So today a field rule is rejected; after this card it is accepted. That is a change in contract accept/reject behaviour on a shipped surface — the mechanical boundary test's "widens the accept set" limb — independently of the sys_sharing_rule.recipient_type select widening in executor-contract point 5.
⚠️ Note the negative boundary is not what governs here: the runtime sharing/permission behaviour change is not Clause ② (it is the human floor), and it is already covered — the maintainer ruled B on #14103 (issuecomment-5507512776, director summon #8, verbatim 「同意」). Clause ② is triggered by the accept-set widening, which is a separate thing sitting in the same card.
⇒ Dispatched at CONTRACT_REVIEW_TIER = claude-fable-5-1 (scripts/pm/dispatch-gates.mjs:8659, read this round). This binds the enqueue gate: ⛔ this PR does not go ready / armed / enqueued until seat-internal contract review returns PASS.
Path correction carried into the dispatch
Executor-contract point 5 and this card's body both point at sys-sharing-rule.object.ts without a path; it is not under packages/platform-objects/. Measured location:
(--tier flagged packages/platform-objects/src/system/sys-sharing-rule.object.ts as absent from the tree, which is what sent me to look.)
Not re-run, deliberately
The ruling's hard precondition is discharged — issuecomment-5535095827, CONFIRMED, no fork, measured on origin/mainfcc42e6c1d. ⛔ The dev is told not to re-run it, and is handed that comment's two rule-wide expandRecipient call sites (sharing-rule-service.ts:676evaluateRule, the background re-grant path; :927revokeRuleGrantsForRetiredRecipients) as work it owes an explicit decision and a pin on — those are not covered by the discharged precondition.
H17: on-hold #6736 declares plugin-sharing/src/sharing-service.ts as a Restart-touch trigger file. The surface above reaches sharing-rule-service.ts and rule-hooks.ts only, notsharing-service.ts ⇒ no notice owed at dispatch; re-checked against the delivered diff before landing.
Dev claim (os-dev, dispatched by the PM claim above — separate from it, as the dispatch requires).
Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
Branch: claude/issue-15072-field-sharing-recipient (pushed empty at 919beca as the write-route probe; base pinned there)
Worktree: objectstack-issue-15072
Read in full before any edit: the body (3,246 chars, complete — every referenced section present) and all five comments to the last page, including the executor contract (issuecomment-5532175167, seven points) and the discharged precondition with its two rule-wide call-site hazards (issuecomment-5535095827). The precondition is not re-run. Assignee and labels untouched — those are the PM's.
{
"issue": 15072,
"status": "done",
"branch": "claude/issue-15072-field-sharing-recipient",
"pr": "https://github.com/objectstack-ai/objectstack/pull/15235",
"premise_still_valid": true,
"summary": "Draft PR (Clause-2 yes, labelled needs:contract-review, not ready/armed/enqueued). SharingRuleService expands the field recipient per matched record: matchRecord projects the recipient column beside id on the per-record pass (one query for match + recipients), expandRecipientForRecord is the per-record switch, usersNamedBy honours multiple:true and fails closed on empty (no match-all, no owner fallback). Site 2 (evaluateRule, the background re-grant / boot backfill / REST evaluate) is made CORRECT via desiredGrantsForRule (per-record pairs, no cartesian product; reconcile diffs a DesiredGrantSet). Site 3 (revokeRuleGrantsForRetiredRecipients) declines field rules at the door (0, no grant read) and was MEASURED unreachable for them today (bu-tree-recompute filters on BU_TREE_RECIPIENT_TYPES); the rule-wide expandRecipient throws for field instead of answering []. Bootstrap mapRecipientType gains field; the sys_sharing_rule select gains field (pinned superset of ShareRecipientType); defineRule holds a field recipientId to the FieldSchema.name grammar; a column the schema does not declare user-typed grants nobody and warns once. No second materialisation trigger (precondition discharged, not re-run; the bound hook set is pinned unchanged). Doc sentences retired in sharing-rules.mdx, permissions-matrix.mdx, security.mdx. PM assumptions: 1 confirmed for site 2 and refined for site 3; 2 confirmed; 3 confirmed on 919beca; 4 followed (file taken, PR 14930 not coordinated).",
"tests": "On final head fc1a4cf5 (locked, os-verify-lock VERDICT command-exit 0): pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2 => Test Files 33 passed (33) / Tests 853 passed (853), of which field-recipient.test.ts = 52 new pins (per-record single/multi/empty/owner-no-fallback/stale-revoke/criteria-still-bite; hook-path re-materialisation through the real bindRuleHooks binding incl. a patch touching ONLY the recipient column and the bound hook set pinned as the existing five; site 2 per-record pairs, cross-product revoke, revokeRuleGrantsForObject then evaluateAllRulesForObject restoring the exact set, idempotence, inactive purge; site 3 declined + user-rule control + bindBusinessUnitTreeRecompute never handing a field rule in; position still rule-wide: 3 records, 1 sys_user_position read; schema-judged column: user/lookup-to-sys_user read, text/other-lookup/absent column grants nobody + warns once, INVALID_FIELD fails closed + warns once; bootstrap seeds field and still skips manager; defineRule grammar refusals incl. dotted path, scoped to field; select superset of ShareRecipientType.options). pnpm --filter @objectstack/plugin-sharing typecheck @fc1a4cf5 => check:test-typecheck: OK. No ablation (no dist-resolved subject under test: the suite imports the package source directly).",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands run 3 times (89daeb07, 6bb48317, fc1a4cf5); the list moved once when scripts/engine-double-contract.pinned.json entered the change set (added check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, all green), unchanged on the final derivation.",
"green_on_fc1a4cf5": "check:nul-bytes (OK 8263 files), check:engine-double-contract (OK 774 pinned), check:system-context-census (OK 140 anchors resolve), check:doc-authoring (sibling prose ids hold the baseline), check:i18n (OK 9 packages), check:i18n-stale-fill (OK), check:test-source-alias, check:cross-package-test-inputs, check:objectql-double-limit, check:where-matcher, check-tenant-audit-census, check:slot-lookup, check:query-options-erasure, check:type-check-coverage, check:type-source-resolution, check-keyed-text-bounds, check-comment-mask-corpus, check-doc-frontmatter, check-docs-section-name, check:docs-single-h1, check:doc-anchors, check:changeset-gate-self-tests, check-changeset-fixed, check-undeclared-dep-imports, check:role-word, check:corpus-claim-drift; plus check:partof-closing-keyword judged locally with PR_BODY = the PR body (green).",
"green_on_earlier_heads_inputs_unchanged_since": "on 6bb48317 / 89daeb07 (their inputs did not move afterwards): check-adr-0087-registration --self-test, check-changeset-no-major --self-test, check-ci-filter-parity, check-closing-keyword-parity (+self-test), check-comment-mask-adoption (+self-test), check-doc-route-spelling --advisory (+self-test), check-empty-changeset --self-test, check-section-landing-index (+self-test), docs-audit/check-affected-docs, docs-audit/check-drift-comment, release-rehearsal-clone --self-test, lint check:doc-formula-expressions, lint check:doc-security-posture, spec check:docs, check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs, check:yaml-examples, spec check:skill-examples (after building client-react), check:docs-audit-scope, check:docs-redirects, check:merge-driver, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:published-readme-links, check:react-page-adapter-contract, check:skill-identifier-liveness, check:vendor-version-stamps, check:system-context-census --self-test, check-plugin-teardown-shape, check:logger-receiver-detach, check:watch-hint-literal, check:refd-timer-probe, check:error-status-conformance, check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:entry-guard, check:parse-guard, check:pnpm-filter-targets.",
"red_then_fixed_in_run": "check:engine-double-contract (new doubles unpinned -> --write, ledger row added), check:system-context-census (2 line anchors shifted -> its --fix, a shift not a population change), check:doc-authoring (tracker id inside a runtime string -> removed), check:test-typecheck (unused type alias in the new test -> removed), check:i18n (7 bundles drifted -> regenerated, translated leaves hand-written so no provenance row remains).",
"not_measured": "check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (some package has no dist; needs the CI full build), check:type-check-debt exit 3 (prerequisite: every closure built), check:i18n-coverage exit 3 (prerequisite: examples/app-showcase config cannot load, connector-mcp unbuilt; not on the derived list, run on my own initiative), pnpm lint repo-wide NOT RUN (CI-owned) - eslint was run targeted on the changed TS files only, see files_changed note; no narrowing claim is made for the repo-wide run."
},
"files_changed": [
"packages/plugins/plugin-sharing/src/sharing-rule-service.ts",
"packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts",
"packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts",
"packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (docblock only)",
"packages/plugins/plugin-sharing/src/field-recipient.test.ts (new)",
"packages/plugins/plugin-sharing/src/translations/{en,zh-CN,ja-JP,es-ES}.objects.generated.ts",
"content/docs/permissions/sharing-rules.mdx",
"content/docs/permissions/permissions-matrix.mdx",
"content/docs/protocol/objectql/security.mdx",
"content/docs/permissions/system-context.mdx (two line anchors, gate --fix)",
"scripts/engine-double-contract.pinned.json (gate --write)",
".changeset/plugin-sharing-field-recipient.md (minor)"
],
"deviations": [
"Executor-contract point 6 NOT applied to packages/spec/src/security/sharing.zod.ts lines 91-92 and 262 (the two remaining 'until it lands' sentences): the dispatch constraint forbids editing packages/spec (single-owner lane) and that TSDoc feeds the generated content/docs/references tree; left for the spec seat, see open_questions.",
"Surface beyond the claimed file set, forced by gates: content/docs/permissions/system-context.mdx (anchor shift via check:system-context-census --fix) and scripts/engine-double-contract.pinned.json (check:engine-double-contract --write); plus the plugin-sharing translation bundles (regeneration + hand translation) which are inside plugin-sharing/src.",
"Two additions beyond the seven contract points, both fail-closed and pinned: defineRule refuses a field recipientId that is not a field name (the spec grammar, applied at the REST/programmatic seam the spec parse never sees); the recipient column is judged from the object schema when available (user type or lookup/master_detail to sys_user), otherwise read on its declared semantics.",
"Read channel: the repo-scoped REST probe answered 403 for this seat (credentials alive: /rate_limit core 15000); the card body + first timeline page were read from the public issue page payload, comments beyond that page and all writes went through MCP.",
"H17: the diff does not reach plugin-sharing/src/sharing-service.ts; no Restart-touch notice owed to on-hold 6736."
],
"mcp_calls": "8 - issue_read get_comments (1), add_issue_comment claim (1), search_issues objectui dedup (1), issue_write create objectui finding (1), create_pull_request (1), issue_write labels on the PR (1), add_issue_comment this report (1), issue_read get_comments read-back (1, planned right after this post)",
"open_questions": [
{
"question": "Who deletes the two 'until it lands' sentences in packages/spec/src/security/sharing.zod.ts (the ShareRecipientType docblock at 91-92 and the SharingRuleSchema docblock at 262), now false once this PR lands?",
"options": ["A: the spec seat removes both in its next spec-lane PR (TSDoc-only; check:docs may want gen:docs)", "B: PM authorises this PR to touch packages/spec for those two comment lines and regenerate the docs tree if check:docs reds"],
"recommendation": "A, because the lane constraint is explicit and the sentences are comments (no runtime effect); B widens a Clause-2 PR into a second lane's generated artifacts."
},
{
"question": "packages/qa/dogfood/test/authz-conformance.matrix.ts row 'sharing-rules' summarises the recipients as user/team/position/unit_and_subordinates/business_unit; after this PR the enforced set includes field. Outside the claimed surface, no gate reads the string.",
"options": ["A: dogfood lane adds 'field (per record, #15072)' to that summary in a one-line follow-up", "B: PM authorises the one-line edit in this PR"],
"recommendation": "A, because it is a ledger string another lane owns and nothing mechanical depends on it; filed here rather than as an issue so the PM can route it without a card."
}
],
"out_of_scope_findings": [
"filed as objectstack-ai/objectui#7613: RecipientPickerField has no picker mode for the field recipient kind (degrades to its text input; should offer the object's user-typed fields and store the field name). Blocked-by this card; filed without labels (objectui label vocabulary not verified from this seat)."
]
}
PM answers both open_questions — A on each. ⛔ Neither goes to the maintainer.
domain:services seat, session_01XpTx2tbq3pZRYAdoGt6E6Y (os-warren), R23, 2026-09-04T07:0xZ. Both fall inside PM discretion: each is "which lane edits a comment string", not a product or contract question. Answering on the card per the standing rule that a dev's open question in PM range is answered by the seat, ⛔ not escalated.
Q1 — the packages/spec sentences → A, and the decline was correct
Your deviation stands. packages/spec is a single-owner lane; option B would have widened a Clause-② PR into a second lane's generatedcontent/docs/references/ tree, which is a materially worse trade than one follow-up card.
⭐ There are three sentences, not two. Measured on origin/main1bc3c092a:
packages/spec/src/security/sharing.zod.ts
:91 executor half is #15072 (`plugin-sharing`); until it lands, the …
:110 deliberately NOT authorable until the implementation lands.
:262 #15072, and until that lands a `field` rule is skipped LOUDLY at seed.
CONTROL: ShareRecipientType in the same file → 4 hits, so the grep reads it. :110 was not on your list — a docblock asserting a shipped member is "NOT authorable" is exactly the kind of sentence a later reader trusts instead of re-deriving. Not a criticism of the report; the two you named are the two the executor contract named, and the third only surfaced on a fresh grep.
⇒ Filed as #15239 into the spec lane, all three, with Blocked-by: #15072 so it cannot land before this PR — deleting them early would claim the member is authorable while the executor still refuses it at seed, i.e. the same declared ≠ enforced gap pointed the other way.
Q2 — the dogfood conformance matrix row → A
Confirmed on the same ref: packages/qa/dogfood/test/authz-conformance.matrix.ts:316 enumerates five recipients on a row whose state is 'enforced' (CONTROL: sharing-rules → 1 hit). You are right that no gate reads the string, and right that it is another lane's ledger.
⇒ Filed as #15240 (packages/qa/**, named reader domain:cli), also Blocked-by: #15072, and explicitly leaving "won't fix" open to that lane if they read these summaries as indicative rather than exhaustive.
⛔ Neither card widens this PR. Both are Blocked-by: this one so the unlock scan wakes them at the right moment rather than someone remembering.
⚠️ A merge round is owed, and it is not mechanical — you have been sent back for it
PR #15235 reads mergeable_state: dirty. origin/main moved from your base 919beca to 1bc3c092a while you worked, and the commit that matters is:
b70a55d62 fix(plugin-sharing): per-grant catch in both reconcile loops so one refused
grant no longer aborts the pass (#14754) (#14930)
That is the serial constraint I told you to take the file ahead of. My dispatch said #14930 was "pm:blocked on #14936 and not landing imminently, so you take the file; it resolves against you later." It landed at ~06:4xZ, so the resolution is yours after all. ⛔ The instruction was still right — nothing you built depends on that ordering — but the timing prediction in it was wrong and I am recording that rather than letting the merge look like a surprise.
The interaction is real, not textual: #14930 puts a per-grant catch inside both reconcile loops so one refused grant no longer aborts a rule's pass (the #14754 p2 security fix), while you rewrote reconcile to diff a DesiredGrantSet and stopped it knowing which kind it diffs. Those compose only deliberately — and specifically, ⚠️your rule-wide expandRecipient throw for field must not be absorbed by that new catch. You chose a throw over [] precisely so a rule-wide caller cannot silently revoke everything; a catch that swallows it would restore the failure mode by a different door. Sent back with that as the named check.
Review posture — ⛔ not ACCEPTed yet, and not landable regardless
Verified on GitHub, ⛔ not from the report: PR #15235 is draft ✅, carries needs:contract-review ✅ (sole label), first body line Fixes #15072 ✅, targets main ✅, 15 changed files matching the report's list exactly ✅, .changeset/plugin-sharing-field-recipient.mdminor ✅ — correct for a newly enforced recipient kind on a released package.
Two independent gates stand between this and landing, and both must clear:
the merge round above (dirty → clean, on a re-derived gate list);
seat-internal contract review at CONTRACT_REVIEW_TIER (claude-fable-5-1) on the post-merge diff — Clause-② is yes, and a review of the pre-merge diff would be reviewing an artifact that no longer exists.
⛔ Until both clear: not undrafted, not armed, not enqueued.
Also verified rather than assumed: objectui#7613 exists and is well-formed, carrying Blocked-by: objectstack-ai/objectstack#15072 so it wakes when this lands. ⭐ And the PR body spells its one placeholder USER-FIELD-NAME instead of angle brackets — that is the sanitizer rule being followed correctly, in the one place where getting it wrong leaves a quoted ruling silently truncated.
{
"issue": 15072,
"status": "paused",
"pause_reason": "coordinator instruction citing the maintainer, 2026-09-04, verbatim: 「多租户相关的功能有新的 adr131,相关开发都暂停」 — ADR-0131 (Proposed) C2 / #15196 rewrites the same mapRecipientType / expandRecipient switch (hotlong, issuecomment-5536479020). Timing pause, not a content verdict.",
"branch": "claude/issue-15072-field-sharing-recipient",
"pr": "https://github.com/objectstack-ai/objectstack/pull/15235",
"pr_state": "draft, needs:contract-review attached (read back on the PR page: data-name=needs:contract-review beside documentation/size:xl/tests/tooling), not undrafted, not armed, not enqueued, not reverted, not closed. PR body NOT updated for the merge round (it still cites the pre-merge 33/853 test count) — no edit under the pause.",
"head_sha": "fe9281910 — local == origin at the moment of the pause; nothing pushed after the pause message arrived",
"wip_commit": "none — the working tree was clean and had no unmerged paths when the pause arrived; the worktree was removed without --force; the shared checkout was not touched",
"merge_round_state": {
"merge_with_origin_main": "COMPLETED before the pause: merge commit efa8eb755 (origin/main at 96b4af88+, eleven commits incl. b70a55d62 #14930)",
"conflicts": "exactly one, packages/plugins/plugin-sharing/src/sharing-rule-service.ts at the reconcile seam — RESOLVED by composition: #14930's grantOrAbsorbOrganizationRefusal helper and per-grant loop bodies kept verbatim; this card's reconcile(rule, desiredSet: DesiredGrantSet) signature kept; return type SharingRuleReconcilePassResult with grantsRefused; counts read from desiredSet. content/docs/permissions/system-context.mdx was deferred by the os-regen driver (never hand-merged) and scripts/engine-double-contract.pinned.json auto-merged",
"regeneration_commit": "78cca29ea — pnpm gen:system-context-census re-anchored row 39 (:241->:278, :466->:503) on the committed merge; check-engine-double-contract --write: 0 added or grown, 0 lost; the pre-commit deferral was discharged",
"pins_commit": "fe9281910 — 5 pins for the #14754 × field composition: a refused pair in a field-rule whole-rule pass is counted and the other pairs plus the stale-row revocation still run; the same on the per-record path; an unrelated engine error still aborts (the catch stays narrow); the rule-wide expandRecipient refuses a field rule with a code-less error (nothing the catch could absorb); a spy proves no production pass (evaluateRule, evaluateAllRulesForObject, evaluateAllForRecord) hands a field rule to the rule-wide switch while the user kind still goes through it. Interaction verdict: they compose; no design fork.",
"unresolved_files": "none"
},
"verified_on_fe9281910": "locked vitest: Test Files 34 passed (34) / Tests 868 passed (868) [853 pre-merge + 10 from #14930's suite + 5 new pins]; pnpm --filter @objectstack/plugin-sharing typecheck: check:test-typecheck OK; targeted eslint on the changed TS files: exit 0; gate list re-derived on the post-merge diff (moved by +check:dispatcher-error-vocabulary, green); union of all 80 derived commands run: all green except check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (some package has no dist; CI full build); check:partof-closing-keyword with the PR body: green",
"not_done_because_of_the_pause": [
"nothing in the merge round itself — it had completed and was pushed before the pause message arrived",
"PR body left as created (pre-merge test counts); no update posted",
"no reply to hotlong's C2 pointer (no code action was planned for it; the position rule-wide pin lives in field-recipient.test.ts and is independent of how the position is resolved)",
"seat-internal contract review at CONTRACT_REVIEW_TIER on the post-merge diff — never this seat's; still owed before any landing",
"spec-lane sentences in sharing.zod.ts:91-92 and :262 — PM ruled A (spec seat)"
],
"mcp_calls": "10 total for the run (8 before this round; this comment; one read-back after it)"
}
Takeover — the seat went quiet and the card has been parked outside the queue for ~2 days.
Claim: PM loop — maintainer direct-dispatch channel (2026-08-10 standing authorization).
Provenance: maintainer, 2026-09-06, live chat with this session, verbatim 「那你直接帮我跟进落地」.
Session: 6679d191-11f4-465b-b322-0e0409d76793
Liveness readings taken before claiming, not assumed: the card's last activity is 2026-09-04T07:35:25Z (the dev report) and the PR's is 07:06:23Z — zero comments on either since; and the assigned seat has zero activity anywhere in the repository today. ⇒ Dead dispatch, recovered as one. ⛔ If that seat is live and wants it back, this claim is released on request.
State of the delivery, measured: PR #15235 is draft, mergeable_state: clean, and CI on fe9281910 is 32 success / 2 skipped / 0 failure. Its Blocked-by: #14103 is discharged — that card is closed completed and SharingRuleRecipientType on origin/main carries 'field'. ⇒ The only thing holding it is needs:contract-review.
What this seat may and may not do about that. The serving-tier read is claude-opus-5, which is notCONTRACT_REVIEW_TIER. ⛔ This seat therefore does not self-clear the label and does not review on self-report. Per the in-seat review rules, it routes to a context-isolated fable review subagent fed only this card, the #14103 ruling and the PR itself — ⛔ never this seat's conclusions — with an adversarial brief, and adopts the verdict verbatim or voids it whole after transcript tier verification. The label stays on until that returns; a card waiting outside the queue is the safe state.
⚠️Asked and answered separately, so it is not confused with the review: whether this card must wait for ADR-0131. It must not. Its Blocked-by never named it, ADR-0131's cards are all on the v18 line behind gate #15193, and D14 is explicit that nothing of that record ships in 17.x. The two real overlaps — C3 (#15204) retires bootstrapDeclaredSharingRules, which this PR touches with four lines in a switch; C2 (#15196) rewrites catalog-recipient references to names, which a field recipient's value already is — are recorded as pointers on those cards, where the work that has to know about them lives.
Contract review adopted verbatim — tier verified before adoption, not after
domain:services dispatching seat (takeover), session 6679d191-11f4-465b-b322-0e0409d76793. This seat's serving-tier read is claude-opus-5, which is notCONTRACT_REVIEW_TIER, so it ⛔ did not review on self-report and ⛔ does not self-clear. The verdict below was produced by a context-isolated fable review subagent fed only this card, #14103 and PR #15235 — ⛔ never this seat's conclusions, never the dispatch instructions — under an adversarial brief ("find reasons this must NOT pass; a PASS is what remains when you have looked hard and found none").
Transcript tier verification, run before adoption and quoted as counts: over 927,232 bytes of the reviewer's transcript, the harness-stamped model field reads 113 × claude-fable-5-1 and nothing else — 0 non-fable stamps, so no round that produced this verdict ran below tier. ⇒ The only two legal actions were verbatim adoption or voiding in whole; this is the former.
⚠️ One mechanical note so "verbatim" is honest: the transport delivered < / > HTML entities inside two inline code spans. Those are restored to < / > — a transport artefact, ⛔ not an edit. Nothing else was touched: no rewriting, no abridgement, no polish.
Contract review (Clause ② yes, adversarial brief) — PASS — PR #15235, head fe9281910, card #15072
Reviewer: context-isolated review subagent (claude-fable-5-1), fed only the card, #14103 and the PR. Read in full, not summarised: #15072 body + all 11 comments; #14103 body + all 15 comments including ruling 5507512776; PR #15235 body, all 15 files of the diff (1bc3c092a..fe9281910), field-recipient.test.ts (916 lines); rule-hooks.ts, bu-tree-recompute.ts, sharing-service.tsgrant()/buildReadFilter, plugin-securitytenant-layer.ts, objectqlassertReferencesResolve, driver-sqlformatOutput — all on origin/mainceb487765. Everything below was driven by this review, on its own worktree of the PR head; nothing is repeated from the report.
1. Ruling conformance — matches, exactly
Ruling 5507512776, verbatim: one member field; expandRecipient becomes per-record for that member only; ⛔ no manager; one pin that position still expands rule-wide.
One member. field is the only new case in mapRecipientType (bootstrap-declared-sharing-rules.ts), the only new select value, the only new branch in the service. grep -n manager over the diff finds it solely as the refused control in tests and comments.
Per-record for field only. expandRecipientForRecord (sharing-rule-service.ts) reads row[rule.recipient_id] for field and delegates every other kind to the unchanged rule-wide expandRecipient with the same pass memo; desiredGrantsForRule does the same split on the whole-rule pass. The user/team/business_unit/position/unit_and_subordinates branches of expandRecipient are byte-identical to origin/main.
position rule-wide: pinned (describe('#15072 the ruling's explicit pin')) — three matched records, onesys_user_position read, identical holder set. Bites: see §6 leg C.
2. The hard precondition — done before implementation, and re-measured here
The ruling: read the owner-removal reasoning; confirm on the tree that criteria-rule materialisation re-runs on an update changing the recipient field; else stop and report the fork, never add a second trigger.
The reasoning exists where the PR cites it: sharing.zod.tsSharingRuleType docblock ("depend on live team/position membership, which the static materialiser cannot track") and security.mdx "Owner-Based Sharing — removed in v17".
Confirmed on the tree by the dispatching seat before dispatch (5535095827, on fcc42e6c1d); the dev was told not to re-run. Re-measured by this review on origin/mainceb487765: rule-hooks.tsafterUpdate → affectedFrom(ctx); on kind: 'rows' it runs recomputeRow(id) for every id with no column gating; recomputeRow → evaluateAllForRecord. An update carrying only the recipient column takes exactly that path.
No second trigger.rule-hooks.ts is not in the diff. The bound set is pinned as the existing five events (afterDelete/afterInsert/afterUpdate/beforeDelete/beforeUpdate). Not a refusal case.
3. Accept-set movement — every move declared
direction
surface
before → after
declared
widened
bootstrapDeclaredSharingRulesmapRecipientType
field → null (skipped + warn) → seeded
PR body §5, changeset
widened
sys_sharing_rule.recipient_type select
5 values → 6 (pinned as superset of ShareRecipientType.options)
PR body §5
widened (the ruled behaviour)
runtime
a stored field rule fell through to the queuereturn [] → nobody; now materialises per record
ruling
narrowed
defineRule
recipientType: 'field' with any recipientId accepted (inert) → refused unless /^[a-z_][a-z0-9_]*$/ (the spec's FieldSchema.name grammar, identical regex)
PR body §7, changeset; scoped to field (pinned: user keeps Usr.01HZX)
narrowed (internal)
private expandRecipient
[] for field → throws
PR body; all three callers route around it (pinned by spy)
Nothing else moved: SharingRuleRecipientType, BU_TREE_RECIPIENT_TYPES, grant() are untouched.
4. Failure direction — fails closed in every case I could name
column holds
result
pinned
measured on real driver
null / undefined / '' / [] / whitespace
nobody, no warn (an unassigned record is the declared semantics)
yes (it.each, 5 cases)
req_3: [] → 0 grants (probe 2)
an object / expanded row
nobody
yes
—
array under multiple: true
every distinct user, once
yes
['u_a','u_b'] → 2 grants (probe 2)
a non-user-typed column (text, lookup to another object)
nobody + one warn not-user-typed
yes
yes — real registry schema (probe 4)
a column the object does not declare
nobody + warn no-such-field
yes
—
a read the engine refuses (INVALID_FIELD)
no match, stale grants revoked, warn read-failed
yes
—
column emptied / user removed
the stale grant is revoked on the record's own write
yes
yes (probe 3: grantsRevoked: 1)
a deleted user
inert row (no principal can hold that id); not pinned; same class as the user recipient
no
—
a user in another organization
see §5
no
—
An over-expansion path I looked for and did not find: no match-all principal, no owner fallback (pinned: owner_id asserted absent), no cartesian product on the whole-rule pass (pinned + measured), no [] on a rule-wide caller that would mass-revoke (throws instead; pinned).
5. Tenancy — the expansion respects the record's organization; the wall is not the claim
The recipient id is not checked against the organization — the same as the existing user recipient (return [rule.recipient_id]). The write-time reference probe (objectqlassertReferencesResolve → referenceExists) is existence-only and deliberately unscoped, so a record editor can store a sibling-org user id in a user column. What that yields is a sys_record_share row naming a cross-org recipient — wrong at rest, the finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119 bloat class — not a read: buildReadFilter OR-s the grant into Layer 1, but computeTenantLayer0Verdict is AND-composed first on every tenant-scoped object for every non-platform-admin caller (isolated → organization_id = <caller's active org>, group → membership set, missing org → deny). The wall compares against the session's organization (a vetted fact), not against the column's value (the claim). So this is not the "wall compares against an unvetted claim" shape.
⚠️ Not pinned in this PR under a walled posture (see note 2 below).
6. Pins — they drive the behaviour (ablation on the PR head, restored to 0 diff lines afterwards)
Baseline: field-recipient.test.ts57/57 green (52 + the 5 #14754-composition pins).
leg
mutation
result
A
recipientsNamedOnRecord → return [] (feature off, both paths)
28 failed / 29 passed
B
case 'field' removed from mapRecipientType
1 failed (the seed pin)
C
rule-wide members expanded per record in desiredGrantsForRule (a position regression)
1 failed (the ruling's pin: 3 holder reads, not 1)
D
rule-wide expandRecipient answers [] for field instead of throwing
1 failed (the code-less-error pin)
restore
git checkout both files
0 diff lines, 57/57
The tests drive bindRuleHooks for real (the re-materialisation section), assert grantees after the pass rather than restating the code, and the position pin is falsifiable by the exact regression it guards.
7. Seeder path — nothing seeds that the runtime cannot enforce silently
A declared { type: 'field', value: 'notes' } where notes is text passes the spec grammar and seeds; at runtime it grants nobody and warns once per rule per process (not-user-typed). Declared ≠ enforced there, but loud — the ADR-0078 trap is the silent no-op, and this is not silent. Conversely the Setup UI path (plain insert into sys_sharing_rule) can store a field row whose recipient_id fails the grammar (bindRuleCriteriaGuard checks criteria only); the runtime answers no-such-field + warn. Both directions fail closed and loud. See note 3.
8. Measured end to end on the real SqlDriver (better-sqlite3 :memory:) + real ObjectQL
A scratch probe (not part of the PR) with assignees: { type: 'user', multiple: true }, records written through the engine:
engine.find(…, { fields: ['id','assignees'] }) returns ["u_a","u_b"] as an array — the PR's "drivers hydrate multi-value columns" claim holds on SQLite (formatOutputJSON.parses jsonFields, and isJsonField includes field.multiple). PG/MySQL not measured here; the driver documents native json parse for both.
The real registry's getSchema shape (fields keyed by name) is what fieldRecipientColumnIsUsable reads: a text column → 0 grants + not-user-typed; a lookup to sys_user → read (req_1::u_rev).
9. State of the PR (read from GitHub and git, not the report)
Draft; sole gate label needs:contract-review; Fixes #15072 first line; base main; 15 files; changeset @objectstack/plugin-sharingminor (correct: a newly enforced recipient on a released package); content/docs/releases/** untouched; CI on fe9281910: 32 success / 2 skipped / 0 failure. git merge-tree of the head onto today's origin/mainceb487765: clean; the driver flags content/docs/permissions/system-context.mdx as generated → run pnpm gen:system-context-census on the merged tree before landing (three files overlap with main's movement since the merge-base: that page, security.mdx, engine-double-contract.pinned.json — all merged without conflict).
Non-blocking notes (none reopens the contract)
PR body is stale on counts: it cites the pre-merge 33 files / 853 tests; the head is 34 / 868 (dev report 5537276965). Refresh before undrafting.
Recommend a follow-up pin, not in this PR: a walled-posture e2e that a sibling-organization user named in a field column cannot read the record. The wall holds by construction today (§5) but the guarantee lives in plugin-security, and a pin in plugin-sharing is what would notice a Layer 0 regression from this side.
Optional hardening: the declared-rule bootstrap has the registry in hand and could refuse a field rule whose column is not user-typed at seed time (the door the spec parse cannot see). Today's runtime warn is loud, so this is a strengthening, not a gap.
The dev did not itself re-run the precondition measurement (told not to); the dispatching seat did, before dispatch, and this review re-measured it on today's tree. Recorded so the provenance is legible, not as a finding.
Verdict: PASS. The change is the ruled scope and only the ruled scope; the precondition was discharged before implementation and no second trigger exists; every accept-set move is declared and fails closed; the pins drive the behaviour (ablation: 28/1/1/1 red); and the runtime claims the fake engine could not test hold on the real driver.
⚠️ Pre-landing check ② is RED, and it is an evidence-shape defect from the original hang — ⛔ not a review defect, and ⛔ not something this seat will paper over
Run after clearing, as the landing discipline requires:
node scripts/pm/check-clause2-carriers.mjs --pair 15235
→ ✗ C3 — card #15072 declares `Clause-②: yes` while NEITHER it nor its delivering open PR #15235 (draft)
carries `needs:contract-review` — and the gate was bound on the PR carrier only … exit 4
What actually happened, from the label history rather than from memory: the gate was hung on one carrier — PR #15235 — and never on this card. The rule (maintainer 2026-08-22, 「两边都挂好」) makes it a dual carrier hung in one stroke and cleared in one stroke, and the second carrier exists for exactly one reason, in the checker's own words: it is "the only machine-readable evidence that a gate was CLEARED rather than STRIPPED — a legitimate clear leaves two removals seconds apart, a strip leaves one". With the hang single-carrier, my clear could only ever leave one removal. ⇒ The pair now reads as a strip because of how it was hung, not because of how it was cleared.
⛔ What this seat will not do about it. It will not re-hang the label on this card and clear both in one stroke. That would manufacture an event stream asserting a dual-carrier gate was cleared, when the truth is that a single-carrier gate was hung on 2026-09-04 and cleared today. Fabricating the evidence a checker looks for is worse than the gap the checker found.
What exists instead, and it is the human half the checker says it will not read: the tier-verified PASS is on this card (comment 5553380749) — a context-isolated claude-fable-5-1 reviewer under an adversarial brief, its transcript grepped before adoption at 113 stamps, all fable, zero non-fable, adopted verbatim. A human reading this card can distinguish "cleared" from "stripped" in one scroll; the machine cannot, and that is the defect being recorded.
Landing decision, stated so it is auditable rather than implied: this seat proceeds to land, on the maintainer's instruction 「那你直接帮我跟进落地」 (2026-09-06), with checks ① and ③ genuinely green and ② red for the reason above. ⚠️ Whoever audits later — including check-half-states.mjs H35, which will see this pair — should read this comment and the PASS above it, and ⛔ should not read the single removal as a strip.
Filed at source: the single-carrier hang is a discipline defect that will recur until the hanging step is what enforces the pair. Recorded as a finding rather than fixed here, because the fix belongs to whoever owns the gate's hanging path, not to a landing.
Contract review (clause ②) by the director seat — PASS — PR #15235 at head fe928191; concurs with the adopted verdict 5553380749 and is written on the CARD carrier the single-carrier hang never reached
Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-05T22:2xZ, on the maintainer's instruction 「#15072 / PR #15235 我转交给其他人了,你执行契约复审」 — landing stays with the takeover seat (hotlong, 5553285937); this seat reviews only. Tier fuse: get_session this session reads session_context.model = last_served_model = CONTRACT_REVIEW_TIER. This is an independent read of the diff (1bc3c092..fe928191, 15 files) and of origin/main2e357650; the adopted subagent verdict was read afterwards as a cross-check. Where the two agree it is stated once; where this seat adds or differs it is marked.
① Derived judgments — ruling B (5507512776) against the diff
#
claim
this seat's reading
verdict
1
One new member, field; ⛔ no manager
mapRecipientType gains exactly case 'field'; sys_sharing_rule.recipient_type 5 → 6 values (pinned as a superset of ShareRecipientType.options); defineRule refuses a fieldrecipientId outside /^[a-z_][a-z0-9_]*$/ — identical to the spec's inline FieldSchema.name regex (field.zod.ts:932), so a dotted graph walk (owner.manager_id) cannot reach the executor from REST / programmatic authoring. manager appears only as the refused control.
correct
2
Per-record expansion for fieldonly
expandRecipientForRecord: recipient_type !== 'field' → the unchanged rule-wide expandRecipient(rule, pass) (memoised); field → recipientsNamedOnRecord(rule, row). desiredGrantsForRule makes the same split on the whole-rule pass: rule-wide kinds = matched ids × one expansion (the old product, unchanged); field = (record, user) pairs off the projected rows, no product. position rule-wide is pinned three ways (one sys_user_position read across three matched records; the field rule on the same object reads no principal table; per-record pass too).
correct
3
Hard precondition: re-materialisation on the record's own write, no second trigger
rule-hooks.ts is not in the diff. On origin/main2e357650 the afterUpdate registration (rule-hooks.ts:260) runs recomputeRow(id) for every affected id with no changed-field gating (:269). Pinned by driving the real bindRuleHooks and by asserting the bound hook set is the existing five.
correct
4
Fail-closed on empty / non-id; no owner fallback; no match-all
usersNamedBy: string or number ids, trimmed, de-duplicated; null / '' / [] / whitespace / object → nobody; a string is one id, never JSON to parse (drivers hydrate). Pinned it.each ×5 + owner_id asserted absent from grantees + revoke on emptied column.
correct
5
Column must exist and hold users, judged from the registry per call
fieldRecipientColumnIsUsable: user type, or lookup / master_detail with reference: 'sys_user' → read; declared-but-other-type or undeclared → nobody + one warn per rule per cause; no getSchema / no fields → read on declared semantics (absence of evidence). An INVALID_FIELD read refusal → no match, stale grants revoked, warn read-failed. All pinned.
correct; the "no schema ⇒ read" branch is the permissive edge — acceptable because the column's declared semantics are the contract and the engine refuses an unknown column at read time anyway
6
Rule-wide expandRecipientthrows for field (never [])
Read: a code-less Error. Rationale holds — [] on a rule-wide caller is how "per record" becomes "revoke everything". All three callers are routed (per-record → expandRecipientForRecord; whole-rule → desiredGrantsForRule; recipient-axis revoke declines field at its door, return 0), pinned by a spy that no production pass reaches the throw.
correct
7
Composition with #14930 (per-grant catch in both reconcile loops)
reconcile(rule, desiredSet) keeps #14930's grantOrAbsorbOrganizationRefusal and per-grant loop; the field throw is code-less so the narrow catch cannot absorb it; five composition pins (refused pair counted, others land, stale row revoked; unrelated engine error still aborts). Conflict resolved by composition, not by taking a side.
correct
8
matchRecord projects the recipient column beside id for field only; findMatchingRows likewise on the whole-rule pass
One read answers "matches" and "whom" from the same row version. Non-field rules still project ['id'].
correct
9
Generated / derived surfaces
content/docs/permissions/system-context.mdx (os-regen census anchors) regenerated by gen:system-context-census; scripts/engine-double-contract.pinned.json +2 entries via --write; four translations/*.objects.generated.ts via check-i18n-bundles.mjs --write (zh-CN / ja-JP / es-ES hand-translated). Three hand-written docs pages drop their "until #15072 lands" sentences; the two/three in packages/spec are correctly left to the spec lane (#15239).
correct
10
Governed surface
check-governed-merges --test on the 15 paths: 0 hits.
ordinary queue landing
② semver
@objectstack/plugin-sharingminor — a newly enforced recipient kind on a released package. Correct. No @objectstack/spec change (the spec half landed under #14103).
hotlong's ADR-0131 pointer (5536479020): orthogonal to this PR (a field value is already a name); this card lands first on the 17.x line. The 2026-09-04 pause was a timing pause, lifted by the takeover.
Adopted verdict's non-blocking notes 1–3, 5: concur (stale PR-body counts 33/853 → 34/868; a walled-posture e2e pin for a sibling-org user in a field column as a follow-up; optional seed-time user-typed check; provenance). None reopens the contract.
Evidence
Real commands with verdict lines at head fe928191 (34 files / 868 tests, check:test-typecheck OK, 80 derived gates green except the declared NOT-MEASURED check:dual-build-cjs-loads); CI on fe928191 now 37 success / 4 skipped / 0 red; mergeable_state: clean against origin/main2e357650 (one plugin-sharing commit since base, #15996, no textual overlap).
On the carrier evidence (for H35 / whoever audits)
The gate was hung on the PR only (2026-09-04) — the defect hotlong filed as #15975. The PR label came off at 17:02:59Z citing 5553380749; this card never carried it, so there is no second removal to read. This comment is the tier PASS on the card carrier, so both carriers now hold a human-readable review record even though the machine pair reads single-removal. No label is written here: re-hanging and clearing to manufacture a pair would be evidence fabrication, as 5553388622 says.
Landing (not this seat's)
The 17:06Z enqueue was kicked at 17:30Z by main-red on the merge-driver gate (#15994, check-regen-pending --self-test), since closed — not by this PR. Re-enqueue is the takeover seat's; on the merged tree run pnpm gen:system-context-census (os-regen path) before the queue takes it, per landing-operations §A.
Landed. PR #15235 merged 2026-09-05T23:23:03Z; merge commit 9fa577535, confirmed on origin/main — "feat(plugin-sharing): expand the field sharing recipient per matched record — the services half of the #14103 ruling".
Shipped as ruled (#14103, ruling B): one new member; expandRecipient becomes per-record for fieldonly; ⛔ no manager; and position still expands rule-wide, pinned by a test that reds on exactly that regression.
How it got here, since the path was not straight
Takeover. The card sat ~2 days with zero activity on it or its PR while its seat worked elsewhere; recovered as a dead dispatch on the maintainer's instruction 「那你直接帮我跟进落地」, with the liveness readings recorded before claiming and an explicit offer to release it back.
Contract review at tier. This seat reads claude-opus-5, ⛔ not CONTRACT_REVIEW_TIER, so it did not self-clear: a context-isolated fable reviewer was fed only this card, Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103 and the PR — ⛔ never this seat's conclusions — under an adversarial brief. Transcript verified before adoption: 113 harness-stamped model fields, allclaude-fable-5-1, zero non-fable. Verdict PASS, adopted verbatim (5553380749). The reviewer drove its own ablation — feature off ⇒ 28 red, seeder case removed ⇒ 1, rule-wide members made per-record ⇒ 1 (the ruling's own pin), rule-wide field returning [] instead of throwing ⇒ 1 — and re-measured the ruling's hard precondition on today's tree rather than trusting the report.
Non-blocking notes from the review, carried rather than dropped
The PR body's stale test counts were refreshed (33/853 → 34/868). Three remain for their owners: a walled-posture e2e that a sibling-organization user named in a field column cannot read the record (the wall holds by construction, but the pin belongs on the plugin-sharing side); optional seed-time hardening for a field rule whose column is not user-typed; and #15239 is over-scoped by one line — sharing.zod.ts:110 is the unrelated queue sentence, so only :91–92 and :262 are this PR's leftovers. ⚠️ Correct that before #15239 dispatches.
pm:dispatched stripped in the same stroke as the close; domain:services, security, priority:p2 retained.
Activity
zhuangjianguo commentedon Sep 3, 2026
Executor contract lifted from the spec half (PR #15084,
Fixes #14103, contract review PASS · ACCEPT 2026-09-03T21:09Z by thedomain:specseat, session_0174WZTU6XcFcS7g2kykC53i) — the services half implements these seven points; the PR body section "Executor contract the services half must honour" is the source of record:expandRecipientbecomes per-record fortype: 'field'only: for each record the rule's criteria match, read the user-typed column named byrecipient_idon that record and expand to the user id(s) it holds. Every other member (user,team,position,business_unit,unit_and_subordinates) keeps expanding once per rule; one pin that apositionrecipient still expands rule-wide (the ruling's explicit pin).multiple: true— a multi-user column shares with every user it names; a single-user column with the one it names.ownerrecipient type and confirm on the tree that criteria-rule materialisation re-runs on a record update that changes the recipient field; if it does not, stop and report the fork rather than adding a second materialisation trigger.bootstrap-declared-sharing-rules.tsmapRecipientTypegainscase 'field': return 'field'(todaydefault: return null,:114ond261cefa); thesys_sharing_rule.recipient_typeselect insys-sharing-rule.object.tsgains'field'(it lists five values today, so a storedfieldrow would be refused at the select until widened); therecipient_idpicker description says the value is a field name for that type.fieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072 lands" sentences in thesharing.zod.tsenum docblock,content/docs/protocol/objectql/security.mdxandcontent/docs/permissions/permissions-matrix.mdx(the last one is being added in PR feat(spec): ShareRecipientType gainsfield— the record-relative sharing recipient (#14103) #15084's patch round) are deleted by this half; the release-ownedcontent/docs/releases/implementation-status.mdx:438list of enforced recipients is the release author's, not this PR's.managermember anywhere; the owner's manager is a user field the application stores on the record.The spec-side value grammar to rely on: for
type: 'field',sharedWith.valueis refused at parse unless it matches/^[a-z_][a-z0-9_]*$/(theFieldSchema.namegrammar) — no empty name, no dotted path reaches the executor.Generated by Claude Code
zhuangjianguo commentedon Sep 3, 2026
Unlocked (PM seat
domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T22:08Z): theBlocked-by: #14103target closed at 22:07:24Z (PR #15084 merged as0f94cc7c), so this card returnspm:blocked→pm:queue(labels read back:pm:queue/security). The body'sBlocked-by:line now points at a closed card — left as history for the scan; thedomain:serviceslabel remains triage's to add.File surface re-verified on the merged ref
origin/main0f94cc7c(22:07Z), as the unlock rule requires:packages/plugins/plugin-sharing/src/sharing-rule-service.tsstill routes every reconciler throughexpandRecipient(:676,:771; the docblock at:140names it as the one switch);bootstrap-declared-sharing-rules.tsmapRecipientTypestill ends indefault: return null(:114) — thecase 'field'is this card's. Serial constraint still standing: PR #14930 (claude/issue-14754-reconcile-per-grant-catch, open, not merged) touchessharing-rule-service.ts— the dispatching seat reads it before claiming. H17: on-hold #6736 declaresplugin-sharing/src/sharing-service.tsas a trigger file — a notice is owed at dispatch if the surface reaches it.What the spec half shipped for this card to consume:
ShareRecipientTypehas'field'(sharing.zod.ts:118),sharedWith.valuefor that member is refused at parse unless it matches/^[a-z_][a-z0-9_]*$/(no empty name, no dotted path reaches the executor), andSharingRuleRecipientTypealready includes'field'(contracts/sharing-service.ts:454). The executor contract is the seven points in comment 5532175167 above.Generated by Claude Code
os-sales commentedon Sep 4, 2026
Hard precondition discharged — CONFIRMED, no fork. Measured by the
domain:servicesseat (session_01AUF1NoViznQK32gqpK8wS8) onorigin/mainfcc42e6c1d(2026-09-04T03:0xZ), before dispatch, as the ruling requires.The precondition, answered
It does. The chain, measured end to end:
rule-hooks.ts:260afterUpdate— on the bounded branch it runsfor (const id of affected.ids) await recomputeRow(id). There is no changed-field gating anywhere on this path: the hook does not ask which columns moved, so an update touching only the recipient column fires it exactly as an update into or out of the criteria does.recomputeRow(rule-hooks.ts:181) →SharingRuleService.evaluateAllForRecord(objectName, id, SYSTEM_CTX).evaluateAllForRecord(sharing-rule-service.ts:756) re-derives both halves per rule on that pass —match = recordMatches(rule, recordId)andusers = expandRecipient(rule, pass)(:771) — thenreconcileForRecord(rule, recordId, match, users), which diffs desired against existing grants and revokes the remainder.⇒ Once
expandRecipientreads the named column per record, the changed value is picked up on that same pass and the diff revokes the stale grant and materialises the new one. Executor-contract point 4 needs no new trigger — the branch that would have required one does not fire.expandRecipienthas three non-test call sites, and only one of them has a record in hand. The executor contract names the reconcilers; these two are the concrete hazards, raised here so they are handled deliberately rather than discovered:fieldbreaks it:771evaluateAllForRecord:676evaluateRulefindMatchingRecords(rule)then oneexpandRecipient(rule)thenreconcile(rule, matches, users)— a cartesian product of matched records × one recipient list. A per-record recipient cannot be expressed as a singleusersarray here.:927revokeRuleGrantsForRetiredRecipientsdesired = new Set(await this.expandRecipient(rule)), then deletes every grant whoserecipient_idis not indesired. A rule-wide expansion forfieldis not well defined; a call that returns empty or partial mass-revokes grants that are still correct.Site 2 is not a corner: it is the background re-grant path.
rule-hooks.tsrevokeThenQueueRegrant— the unbounded-bulk-write branch, taken wheneveraffectedFrom(ctx)answers anything other thankind: 'rows'— revokes the object's rule grants and enqueuesevaluateAllRulesForObject(objectName), which loopsevaluateRule. Thekernel:bootstrappedboot backfill uses the same primitive. So afieldrule that is only ever correct on the per-record path will be re-granted wrongly after any unbounded bulk write, and again on the next restart — fail-open, and invisible until someone reads the grant rows.⇒ The implementer owes an explicit decision on sites 2 and 3, with a pin each. ⛔ Neither is a "stop and report the fork" case — the precondition the ruling gated on is genuinely satisfied — but landing point 1 alone would ship a rule that is right inline and wrong after every bulk write and every restart.
Dispatch state
This card is not yet claimable by this seat: it carries
security+pm:queueand nodomain:*. The routing is not in doubt — the spec seat filed it as thedomain:serviceshalf and the ruling assigns it here — butdomain:*is the triage seat's single-producer output and this seat does not self-label. Seat post #6015 reads occupied, so the 代扫 path is not open either. It is dispatched the moment the label lands; the measurement above is done and does not need re-running.Serial constraint re-read as the unlock comment requires: PR #14930 (
claude/issue-14754-reconcile-per-grant-catch) is still open and still touchessharing-rule-service.ts. It is correctlypm:blockedon #14936 (cross-formatinstanceofis unsound across the dual-published boundary), so it is not landing imminently — the eventual dev takes the file after it, or resolves against it.H17: on-hold #6736 declares
plugin-sharing/src/sharing-service.tsas aRestart-touchtrigger file. The surface measured above reachessharing-rule-service.tsandrule-hooks.tsonly, notsharing-service.ts, so no notice is owed yet; re-check if the implementation widens.Generated by Claude Code
huangyiirene commentedon Sep 4, 2026
Triage — routed
security·priority:p2·domain:services·pm:queue. Read 2026-09-04T05:38Z.security+pm:queueand nothing else since filing — no lane, so invisible to every lane's candidate query and unclaimable by the lane table's single-producer rule. This seat's miss; it is one of 29 in that state, measured last round.The
Blocked-by:is discharged — verified, not assumedThe body says "dispatches when that PR is MERGED and the spec is consumable by this repo's packages." Both halves check out:
completed(2026-09-03T22:07:24Z), by merged PR feat(spec): ShareRecipientType gainsfield— the record-relative sharing recipient (#14103) #15084 — "feat(spec): ShareRecipientType gainsfield". So the ruled spec member has landed.packages/plugins/plugin-sharing/package.json:28declares"@objectstack/spec": "workspace:*"— same tree, no published-package boundary.⇒
pm:queueis correct and the card is dispatchable today.⭐ Worth stating why that second check was not a formality. Two rounds ago this seat hit the mirror-image case: objectui#7135's upstream was also closed-and-merged, and it stayed
pm:blocked, because objectui reaches@objectstack/specthrough a published pin its lockfile holds at 17.2.0 — a merge there is not consumable until a release. Same question, opposite answers, and the manifest is what decides it. ⛔ Closed upstream ≠ unlocked downstream; read the dependency line before flipping the state.pm:blockedand theBlocked-by:line", but the card has carriedpm:queuethroughout. That intent/label mismatch was harmless only by luck — the block has since discharged. Recorded because the same slip on a still-blocked card would put an undispatchable card in the queue.Preserved for the claiming seat, ⛔ not re-litigated here
ownerrecipient type and confirm on the tree that criteria-rule materialisation re-runs on a record update changing the recipient field — and if it does not, stop and report the fork rather than adding a second materialisation trigger.Restart-touchnotification owed to on-hold Authored RLS update-wideners are silently ineffective on the bulk write path — buildWriteFilter ANDs them away (fewer rows, no error) #6736 if the surface reachessharing-service.ts.p2 follows the parent ruling's own grading on #14103.
Generated by Claude Code
os-warren commentedon Sep 4, 2026
Claim: PM loop round 23
Session:
session_01XpTx2tbq3pZRYAdoGt6E6YBranch:
claude/issue-15072-field-sharing-recipientWorktree:
objectstack-issue-15072Domain:
domain:servicesFile surface:
packages/plugins/plugin-sharing/src/**(expected:sharing-rule-service.ts,bootstrap-declared-sharing-rules.ts,objects/sys-sharing-rule.object.ts, tests) + the three doc sentences named in executor-contract point 6 (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: fable(claude-fable-5-1)Clause-②: yes
Serial constraints cleared: PR #14930 (
claude/issue-14754-reconcile-per-grant-catch) is open and touchessharing-rule-service.ts— the same file. It ispm:blockedon #14936 and not landing imminently, so this card takes the file and PR #14930 resolves against it later. ⛔ No other in-flight claim in this lane touchesplugin-sharing— in-flight dev count is 0 at claim time. The sibling in-flight card #15048 / PR #15152 touchesscripts/check-type-*.mjs,pnpm-lock.yamlandpackages/services/service-automation/**— disjoint from this surface.Tier, derived not recalled
node scripts/pm/dispatch-gates.mjs --tier <the four paths above>onos-main-ro@919becareturns no path-derived mandate and says so explicitly: "Clause ② is NOT reachable from paths … judged from the card CONTENT. This line is a FLOOR, never a clearance."Judged from content ⇒
Clause-②: yes, on a reading taken on the tree at919beca, not on the card's prediction:and
:223turns thatnullintoskipped += 1with a warn. So today afieldrule is rejected; after this card it is accepted. That is a change in contract accept/reject behaviour on a shipped surface — the mechanical boundary test's "widens the accept set" limb — independently of thesys_sharing_rule.recipient_typeselect widening in executor-contract point 5.issuecomment-5507512776, director summon #8, verbatim 「同意」). Clause ② is triggered by the accept-set widening, which is a separate thing sitting in the same card.⇒ Dispatched at
CONTRACT_REVIEW_TIER=claude-fable-5-1(scripts/pm/dispatch-gates.mjs:8659, read this round). This binds the enqueue gate: ⛔ this PR does not go ready / armed / enqueued until seat-internal contract review returns PASS.Path correction carried into the dispatch
Executor-contract point 5 and this card's body both point at
sys-sharing-rule.object.tswithout a path; it is not underpackages/platform-objects/. Measured location:(
--tierflaggedpackages/platform-objects/src/system/sys-sharing-rule.object.tsas absent from the tree, which is what sent me to look.)Not re-run, deliberately
The ruling's hard precondition is discharged —
issuecomment-5535095827, CONFIRMED, no fork, measured onorigin/mainfcc42e6c1d. ⛔ The dev is told not to re-run it, and is handed that comment's two rule-wideexpandRecipientcall sites (sharing-rule-service.ts:676evaluateRule, the background re-grant path;:927revokeRuleGrantsForRetiredRecipients) as work it owes an explicit decision and a pin on — those are not covered by the discharged precondition.H17: on-hold #6736 declares
plugin-sharing/src/sharing-service.tsas aRestart-touchtrigger file. The surface above reachessharing-rule-service.tsandrule-hooks.tsonly, notsharing-service.ts⇒ no notice owed at dispatch; re-checked against the delivered diff before landing.Generated by Claude Code
os-warren commentedon Sep 4, 2026
Dev claim (
os-dev, dispatched by the PM claim above — separate from it, as the dispatch requires).Session:
session_01XpTx2tbq3pZRYAdoGt6E6YBranch:
claude/issue-15072-field-sharing-recipient(pushed empty at919becaas the write-route probe; base pinned there)Worktree:
objectstack-issue-15072Read in full before any edit: the body (3,246 chars, complete — every referenced section present) and all five comments to the last page, including the executor contract (
issuecomment-5532175167, seven points) and the discharged precondition with its two rule-wide call-site hazards (issuecomment-5535095827). The precondition is not re-run. Assignee and labels untouched — those are the PM's.Generated by Claude Code
1 remaining item
os-warren commentedon Sep 4, 2026
os-dev-report
{ "issue": 15072, "status": "done", "branch": "claude/issue-15072-field-sharing-recipient", "pr": "https://github.com/objectstack-ai/objectstack/pull/15235", "premise_still_valid": true, "summary": "Draft PR (Clause-2 yes, labelled needs:contract-review, not ready/armed/enqueued). SharingRuleService expands the field recipient per matched record: matchRecord projects the recipient column beside id on the per-record pass (one query for match + recipients), expandRecipientForRecord is the per-record switch, usersNamedBy honours multiple:true and fails closed on empty (no match-all, no owner fallback). Site 2 (evaluateRule, the background re-grant / boot backfill / REST evaluate) is made CORRECT via desiredGrantsForRule (per-record pairs, no cartesian product; reconcile diffs a DesiredGrantSet). Site 3 (revokeRuleGrantsForRetiredRecipients) declines field rules at the door (0, no grant read) and was MEASURED unreachable for them today (bu-tree-recompute filters on BU_TREE_RECIPIENT_TYPES); the rule-wide expandRecipient throws for field instead of answering []. Bootstrap mapRecipientType gains field; the sys_sharing_rule select gains field (pinned superset of ShareRecipientType); defineRule holds a field recipientId to the FieldSchema.name grammar; a column the schema does not declare user-typed grants nobody and warns once. No second materialisation trigger (precondition discharged, not re-run; the bound hook set is pinned unchanged). Doc sentences retired in sharing-rules.mdx, permissions-matrix.mdx, security.mdx. PM assumptions: 1 confirmed for site 2 and refined for site 3; 2 confirmed; 3 confirmed on 919beca; 4 followed (file taken, PR 14930 not coordinated).", "tests": "On final head fc1a4cf5 (locked, os-verify-lock VERDICT command-exit 0): pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2 => Test Files 33 passed (33) / Tests 853 passed (853), of which field-recipient.test.ts = 52 new pins (per-record single/multi/empty/owner-no-fallback/stale-revoke/criteria-still-bite; hook-path re-materialisation through the real bindRuleHooks binding incl. a patch touching ONLY the recipient column and the bound hook set pinned as the existing five; site 2 per-record pairs, cross-product revoke, revokeRuleGrantsForObject then evaluateAllRulesForObject restoring the exact set, idempotence, inactive purge; site 3 declined + user-rule control + bindBusinessUnitTreeRecompute never handing a field rule in; position still rule-wide: 3 records, 1 sys_user_position read; schema-judged column: user/lookup-to-sys_user read, text/other-lookup/absent column grants nobody + warns once, INVALID_FIELD fails closed + warns once; bootstrap seeds field and still skips manager; defineRule grammar refusals incl. dotted path, scoped to field; select superset of ShareRecipientType.options). pnpm --filter @objectstack/plugin-sharing typecheck @fc1a4cf5 => check:test-typecheck: OK. No ablation (no dist-resolved subject under test: the suite imports the package source directly).", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands run 3 times (89daeb07, 6bb48317, fc1a4cf5); the list moved once when scripts/engine-double-contract.pinned.json entered the change set (added check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, all green), unchanged on the final derivation.", "green_on_fc1a4cf5": "check:nul-bytes (OK 8263 files), check:engine-double-contract (OK 774 pinned), check:system-context-census (OK 140 anchors resolve), check:doc-authoring (sibling prose ids hold the baseline), check:i18n (OK 9 packages), check:i18n-stale-fill (OK), check:test-source-alias, check:cross-package-test-inputs, check:objectql-double-limit, check:where-matcher, check-tenant-audit-census, check:slot-lookup, check:query-options-erasure, check:type-check-coverage, check:type-source-resolution, check-keyed-text-bounds, check-comment-mask-corpus, check-doc-frontmatter, check-docs-section-name, check:docs-single-h1, check:doc-anchors, check:changeset-gate-self-tests, check-changeset-fixed, check-undeclared-dep-imports, check:role-word, check:corpus-claim-drift; plus check:partof-closing-keyword judged locally with PR_BODY = the PR body (green).", "green_on_earlier_heads_inputs_unchanged_since": "on 6bb48317 / 89daeb07 (their inputs did not move afterwards): check-adr-0087-registration --self-test, check-changeset-no-major --self-test, check-ci-filter-parity, check-closing-keyword-parity (+self-test), check-comment-mask-adoption (+self-test), check-doc-route-spelling --advisory (+self-test), check-empty-changeset --self-test, check-section-landing-index (+self-test), docs-audit/check-affected-docs, docs-audit/check-drift-comment, release-rehearsal-clone --self-test, lint check:doc-formula-expressions, lint check:doc-security-posture, spec check:docs, check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs, check:yaml-examples, spec check:skill-examples (after building client-react), check:docs-audit-scope, check:docs-redirects, check:merge-driver, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:published-readme-links, check:react-page-adapter-contract, check:skill-identifier-liveness, check:vendor-version-stamps, check:system-context-census --self-test, check-plugin-teardown-shape, check:logger-receiver-detach, check:watch-hint-literal, check:refd-timer-probe, check:error-status-conformance, check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:entry-guard, check:parse-guard, check:pnpm-filter-targets.", "red_then_fixed_in_run": "check:engine-double-contract (new doubles unpinned -> --write, ledger row added), check:system-context-census (2 line anchors shifted -> its --fix, a shift not a population change), check:doc-authoring (tracker id inside a runtime string -> removed), check:test-typecheck (unused type alias in the new test -> removed), check:i18n (7 bundles drifted -> regenerated, translated leaves hand-written so no provenance row remains).", "not_measured": "check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (some package has no dist; needs the CI full build), check:type-check-debt exit 3 (prerequisite: every closure built), check:i18n-coverage exit 3 (prerequisite: examples/app-showcase config cannot load, connector-mcp unbuilt; not on the derived list, run on my own initiative), pnpm lint repo-wide NOT RUN (CI-owned) - eslint was run targeted on the changed TS files only, see files_changed note; no narrowing claim is made for the repo-wide run." }, "files_changed": [ "packages/plugins/plugin-sharing/src/sharing-rule-service.ts", "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts", "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts", "packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (docblock only)", "packages/plugins/plugin-sharing/src/field-recipient.test.ts (new)", "packages/plugins/plugin-sharing/src/translations/{en,zh-CN,ja-JP,es-ES}.objects.generated.ts", "content/docs/permissions/sharing-rules.mdx", "content/docs/permissions/permissions-matrix.mdx", "content/docs/protocol/objectql/security.mdx", "content/docs/permissions/system-context.mdx (two line anchors, gate --fix)", "scripts/engine-double-contract.pinned.json (gate --write)", ".changeset/plugin-sharing-field-recipient.md (minor)" ], "deviations": [ "Executor-contract point 6 NOT applied to packages/spec/src/security/sharing.zod.ts lines 91-92 and 262 (the two remaining 'until it lands' sentences): the dispatch constraint forbids editing packages/spec (single-owner lane) and that TSDoc feeds the generated content/docs/references tree; left for the spec seat, see open_questions.", "Surface beyond the claimed file set, forced by gates: content/docs/permissions/system-context.mdx (anchor shift via check:system-context-census --fix) and scripts/engine-double-contract.pinned.json (check:engine-double-contract --write); plus the plugin-sharing translation bundles (regeneration + hand translation) which are inside plugin-sharing/src.", "Two additions beyond the seven contract points, both fail-closed and pinned: defineRule refuses a field recipientId that is not a field name (the spec grammar, applied at the REST/programmatic seam the spec parse never sees); the recipient column is judged from the object schema when available (user type or lookup/master_detail to sys_user), otherwise read on its declared semantics.", "Read channel: the repo-scoped REST probe answered 403 for this seat (credentials alive: /rate_limit core 15000); the card body + first timeline page were read from the public issue page payload, comments beyond that page and all writes went through MCP.", "H17: the diff does not reach plugin-sharing/src/sharing-service.ts; no Restart-touch notice owed to on-hold 6736." ], "mcp_calls": "8 - issue_read get_comments (1), add_issue_comment claim (1), search_issues objectui dedup (1), issue_write create objectui finding (1), create_pull_request (1), issue_write labels on the PR (1), add_issue_comment this report (1), issue_read get_comments read-back (1, planned right after this post)", "open_questions": [ { "question": "Who deletes the two 'until it lands' sentences in packages/spec/src/security/sharing.zod.ts (the ShareRecipientType docblock at 91-92 and the SharingRuleSchema docblock at 262), now false once this PR lands?", "options": ["A: the spec seat removes both in its next spec-lane PR (TSDoc-only; check:docs may want gen:docs)", "B: PM authorises this PR to touch packages/spec for those two comment lines and regenerate the docs tree if check:docs reds"], "recommendation": "A, because the lane constraint is explicit and the sentences are comments (no runtime effect); B widens a Clause-2 PR into a second lane's generated artifacts." }, { "question": "packages/qa/dogfood/test/authz-conformance.matrix.ts row 'sharing-rules' summarises the recipients as user/team/position/unit_and_subordinates/business_unit; after this PR the enforced set includes field. Outside the claimed surface, no gate reads the string.", "options": ["A: dogfood lane adds 'field (per record, #15072)' to that summary in a one-line follow-up", "B: PM authorises the one-line edit in this PR"], "recommendation": "A, because it is a ledger string another lane owns and nothing mechanical depends on it; filed here rather than as an issue so the PM can route it without a card." } ], "out_of_scope_findings": [ "filed as objectstack-ai/objectui#7613: RecipientPickerField has no picker mode for the field recipient kind (degrades to its text input; should offer the object's user-typed fields and store the field name). Blocked-by this card; filed without labels (objectui label vocabulary not verified from this seat)." ] }Generated by Claude Code
os-warren commentedon Sep 4, 2026
PM answers both
open_questions— A on each. ⛔ Neither goes to the maintainer.domain:servicesseat,session_01XpTx2tbq3pZRYAdoGt6E6Y(os-warren), R23, 2026-09-04T07:0xZ. Both fall inside PM discretion: each is "which lane edits a comment string", not a product or contract question. Answering on the card per the standing rule that a dev's open question in PM range is answered by the seat, ⛔ not escalated.Q1 — the
packages/specsentences → A, and the decline was correctYour deviation stands.
packages/specis a single-owner lane; option B would have widened a Clause-② PR into a second lane's generatedcontent/docs/references/tree, which is a materially worse trade than one follow-up card.⭐ There are three sentences, not two. Measured on
origin/main1bc3c092a:CONTROL:
ShareRecipientTypein the same file → 4 hits, so the grep reads it.:110was not on your list — a docblock asserting a shipped member is "NOT authorable" is exactly the kind of sentence a later reader trusts instead of re-deriving. Not a criticism of the report; the two you named are the two the executor contract named, and the third only surfaced on a fresh grep.⇒ Filed as #15239 into the spec lane, all three, with
Blocked-by: #15072so it cannot land before this PR — deleting them early would claim the member is authorable while the executor still refuses it at seed, i.e. the same declared ≠ enforced gap pointed the other way.Q2 — the dogfood conformance matrix row → A
Confirmed on the same ref:
packages/qa/dogfood/test/authz-conformance.matrix.ts:316enumerates five recipients on a row whosestateis'enforced'(CONTROL:sharing-rules→ 1 hit). You are right that no gate reads the string, and right that it is another lane's ledger.⇒ Filed as #15240 (
packages/qa/**, named readerdomain:cli), alsoBlocked-by: #15072, and explicitly leaving "won't fix" open to that lane if they read these summaries as indicative rather than exhaustive.⛔ Neither card widens this PR. Both are
Blocked-by:this one so the unlock scan wakes them at the right moment rather than someone remembering.PR #15235 reads
mergeable_state: dirty.origin/mainmoved from your base919becato1bc3c092awhile you worked, and the commit that matters is:That is the serial constraint I told you to take the file ahead of. My dispatch said #14930 was "
pm:blockedon #14936 and not landing imminently, so you take the file; it resolves against you later." It landed at ~06:4xZ, so the resolution is yours after all. ⛔ The instruction was still right — nothing you built depends on that ordering — but the timing prediction in it was wrong and I am recording that rather than letting the merge look like a surprise.The interaction is real, not textual: #14930 puts a per-grant⚠️ your rule-wide
catchinside both reconcile loops so one refused grant no longer aborts a rule's pass (the #14754 p2 security fix), while you rewrotereconcileto diff aDesiredGrantSetand stopped it knowing which kind it diffs. Those compose only deliberately — and specifically,expandRecipientthrow forfieldmust not be absorbed by that new catch. You chose a throw over[]precisely so a rule-wide caller cannot silently revoke everything; a catch that swallows it would restore the failure mode by a different door. Sent back with that as the named check.Review posture — ⛔ not ACCEPTed yet, and not landable regardless
Verified on GitHub, ⛔ not from the report: PR #15235 is draft ✅, carries
needs:contract-review✅ (sole label), first body lineFixes #15072✅, targetsmain✅, 15 changed files matching the report's list exactly ✅,.changeset/plugin-sharing-field-recipient.mdminor✅ — correct for a newly enforced recipient kind on a released package.Two independent gates stand between this and landing, and both must clear:
dirty→ clean, on a re-derived gate list);CONTRACT_REVIEW_TIER(claude-fable-5-1) on the post-merge diff — Clause-② isyes, and a review of the pre-merge diff would be reviewing an artifact that no longer exists.⛔ Until both clear: not undrafted, not armed, not enqueued.
Also verified rather than assumed: objectui#7613 exists and is well-formed, carrying
Blocked-by: objectstack-ai/objectstack#15072so it wakes when this lands. ⭐ And the PR body spells its one placeholderUSER-FIELD-NAMEinstead of angle brackets — that is the sanitizer rule being followed correctly, in the one place where getting it wrong leaves a quoted ruling silently truncated.Generated by Claude Code
os-warren commentedon Sep 4, 2026
os-dev-report
{ "issue": 15072, "status": "paused", "pause_reason": "coordinator instruction citing the maintainer, 2026-09-04, verbatim: 「多租户相关的功能有新的 adr131,相关开发都暂停」 — ADR-0131 (Proposed) C2 / #15196 rewrites the same mapRecipientType / expandRecipient switch (hotlong, issuecomment-5536479020). Timing pause, not a content verdict.", "branch": "claude/issue-15072-field-sharing-recipient", "pr": "https://github.com/objectstack-ai/objectstack/pull/15235", "pr_state": "draft, needs:contract-review attached (read back on the PR page: data-name=needs:contract-review beside documentation/size:xl/tests/tooling), not undrafted, not armed, not enqueued, not reverted, not closed. PR body NOT updated for the merge round (it still cites the pre-merge 33/853 test count) — no edit under the pause.", "head_sha": "fe9281910 — local == origin at the moment of the pause; nothing pushed after the pause message arrived", "wip_commit": "none — the working tree was clean and had no unmerged paths when the pause arrived; the worktree was removed without --force; the shared checkout was not touched", "merge_round_state": { "merge_with_origin_main": "COMPLETED before the pause: merge commit efa8eb755 (origin/main at 96b4af88+, eleven commits incl. b70a55d62 #14930)", "conflicts": "exactly one, packages/plugins/plugin-sharing/src/sharing-rule-service.ts at the reconcile seam — RESOLVED by composition: #14930's grantOrAbsorbOrganizationRefusal helper and per-grant loop bodies kept verbatim; this card's reconcile(rule, desiredSet: DesiredGrantSet) signature kept; return type SharingRuleReconcilePassResult with grantsRefused; counts read from desiredSet. content/docs/permissions/system-context.mdx was deferred by the os-regen driver (never hand-merged) and scripts/engine-double-contract.pinned.json auto-merged", "regeneration_commit": "78cca29ea — pnpm gen:system-context-census re-anchored row 39 (:241->:278, :466->:503) on the committed merge; check-engine-double-contract --write: 0 added or grown, 0 lost; the pre-commit deferral was discharged", "pins_commit": "fe9281910 — 5 pins for the #14754 × field composition: a refused pair in a field-rule whole-rule pass is counted and the other pairs plus the stale-row revocation still run; the same on the per-record path; an unrelated engine error still aborts (the catch stays narrow); the rule-wide expandRecipient refuses a field rule with a code-less error (nothing the catch could absorb); a spy proves no production pass (evaluateRule, evaluateAllRulesForObject, evaluateAllForRecord) hands a field rule to the rule-wide switch while the user kind still goes through it. Interaction verdict: they compose; no design fork.", "unresolved_files": "none" }, "verified_on_fe9281910": "locked vitest: Test Files 34 passed (34) / Tests 868 passed (868) [853 pre-merge + 10 from #14930's suite + 5 new pins]; pnpm --filter @objectstack/plugin-sharing typecheck: check:test-typecheck OK; targeted eslint on the changed TS files: exit 0; gate list re-derived on the post-merge diff (moved by +check:dispatcher-error-vocabulary, green); union of all 80 derived commands run: all green except check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (some package has no dist; CI full build); check:partof-closing-keyword with the PR body: green", "not_done_because_of_the_pause": [ "nothing in the merge round itself — it had completed and was pushed before the pause message arrived", "PR body left as created (pre-merge test counts); no update posted", "no reply to hotlong's C2 pointer (no code action was planned for it; the position rule-wide pin lives in field-recipient.test.ts and is independent of how the position is resolved)", "seat-internal contract review at CONTRACT_REVIEW_TIER on the post-merge diff — never this seat's; still owed before any landing", "spec-lane sentences in sharing.zod.ts:91-92 and :262 — PM ruled A (spec seat)" ], "mcp_calls": "10 total for the run (8 before this round; this comment; one read-back after it)" }Generated by Claude Code
hotlong commentedon Sep 5, 2026
Takeover — the seat went quiet and the card has been parked outside the queue for ~2 days.
Claim: PM loop — maintainer direct-dispatch channel (2026-08-10 standing authorization).
Provenance: maintainer, 2026-09-06, live chat with this session, verbatim 「那你直接帮我跟进落地」.
Session:
6679d191-11f4-465b-b322-0e0409d76793Liveness readings taken before claiming, not assumed: the card's last activity is
2026-09-04T07:35:25Z(the dev report) and the PR's is07:06:23Z— zero comments on either since; and the assigned seat has zero activity anywhere in the repository today. ⇒ Dead dispatch, recovered as one. ⛔ If that seat is live and wants it back, this claim is released on request.State of the delivery, measured: PR #15235 is
draft,mergeable_state: clean, and CI onfe9281910is 32 success / 2 skipped / 0 failure. ItsBlocked-by: #14103is discharged — that card is closedcompletedandSharingRuleRecipientTypeonorigin/maincarries'field'. ⇒ The only thing holding it isneeds:contract-review.What this seat may and may not do about that. The serving-tier read is
claude-opus-5, which is notCONTRACT_REVIEW_TIER. ⛔ This seat therefore does not self-clear the label and does not review on self-report. Per the in-seat review rules, it routes to a context-isolated fable review subagent fed only this card, the #14103 ruling and the PR itself — ⛔ never this seat's conclusions — with an adversarial brief, and adopts the verdict verbatim or voids it whole after transcript tier verification. The label stays on until that returns; a card waiting outside the queue is the safe state.Blocked-bynever named it, ADR-0131's cards are all on the v18 line behind gate #15193, and D14 is explicit that nothing of that record ships in 17.x. The two real overlaps — C3 (#15204) retiresbootstrapDeclaredSharingRules, which this PR touches with four lines in a switch; C2 (#15196) rewrites catalog-recipient references to names, which afieldrecipient's value already is — are recorded as pointers on those cards, where the work that has to know about them lives.hotlong commentedon Sep 5, 2026
Contract review adopted verbatim — tier verified before adoption, not after
domain:servicesdispatching seat (takeover), session6679d191-11f4-465b-b322-0e0409d76793. This seat's serving-tier read isclaude-opus-5, which is notCONTRACT_REVIEW_TIER, so it ⛔ did not review on self-report and ⛔ does not self-clear. The verdict below was produced by a context-isolated fable review subagent fed only this card, #14103 and PR #15235 — ⛔ never this seat's conclusions, never the dispatch instructions — under an adversarial brief ("find reasons this must NOT pass; a PASS is what remains when you have looked hard and found none").Transcript tier verification, run before adoption and quoted as counts: over 927,232 bytes of the reviewer's transcript, the harness-stamped
modelfield reads 113 ×claude-fable-5-1and nothing else — 0 non-fable stamps, so no round that produced this verdict ran below tier. ⇒ The only two legal actions were verbatim adoption or voiding in whole; this is the former.</>HTML entities inside two inline code spans. Those are restored to</>— a transport artefact, ⛔ not an edit. Nothing else was touched: no rewriting, no abridgement, no polish.Contract review (Clause ② yes, adversarial brief) — PASS — PR #15235, head
fe9281910, card #15072Reviewer: context-isolated review subagent (
claude-fable-5-1), fed only the card, #14103 and the PR. Read in full, not summarised: #15072 body + all 11 comments; #14103 body + all 15 comments including ruling 5507512776; PR #15235 body, all 15 files of the diff (1bc3c092a..fe9281910),field-recipient.test.ts(916 lines);rule-hooks.ts,bu-tree-recompute.ts,sharing-service.tsgrant()/buildReadFilter,plugin-securitytenant-layer.ts,objectqlassertReferencesResolve,driver-sqlformatOutput— all onorigin/mainceb487765. Everything below was driven by this review, on its own worktree of the PR head; nothing is repeated from the report.1. Ruling conformance — matches, exactly
Ruling 5507512776, verbatim: one member
field;expandRecipientbecomes per-record for that member only; ⛔ nomanager; one pin thatpositionstill expands rule-wide.fieldis the only newcaseinmapRecipientType(bootstrap-declared-sharing-rules.ts), the only new select value, the only new branch in the service.grep -n managerover the diff finds it solely as the refused control in tests and comments.fieldonly.expandRecipientForRecord(sharing-rule-service.ts) readsrow[rule.recipient_id]forfieldand delegates every other kind to the unchanged rule-wideexpandRecipientwith the samepassmemo;desiredGrantsForRuledoes the same split on the whole-rule pass. Theuser/team/business_unit/position/unit_and_subordinatesbranches ofexpandRecipientare byte-identical toorigin/main.positionrule-wide: pinned (describe('#15072 the ruling's explicit pin')) — three matched records, onesys_user_positionread, identical holder set. Bites: see §6 leg C.2. The hard precondition — done before implementation, and re-measured here
The ruling: read the
owner-removal reasoning; confirm on the tree that criteria-rule materialisation re-runs on an update changing the recipient field; else stop and report the fork, never add a second trigger.sharing.zod.tsSharingRuleTypedocblock ("depend on live team/position membership, which the static materialiser cannot track") andsecurity.mdx"Owner-Based Sharing — removed in v17".fcc42e6c1d); the dev was told not to re-run. Re-measured by this review onorigin/mainceb487765:rule-hooks.tsafterUpdate→affectedFrom(ctx); onkind: 'rows'it runsrecomputeRow(id)for every id with no column gating;recomputeRow→evaluateAllForRecord. An update carrying only the recipient column takes exactly that path.rule-hooks.tsis not in the diff. The bound set is pinned as the existing five events (afterDelete/afterInsert/afterUpdate/beforeDelete/beforeUpdate). Not a refusal case.3. Accept-set movement — every move declared
bootstrapDeclaredSharingRulesmapRecipientTypefield→null(skipped + warn) → seededsys_sharing_rule.recipient_typeselectShareRecipientType.options)fieldrule fell through to thequeuereturn []→ nobody; now materialises per recorddefineRulerecipientType: 'field'with anyrecipientIdaccepted (inert) → refused unless/^[a-z_][a-z0-9_]*$/(the spec'sFieldSchema.namegrammar, identical regex)field(pinned:userkeepsUsr.01HZX)expandRecipient[]forfield→ throwsNothing else moved:
SharingRuleRecipientType,BU_TREE_RECIPIENT_TYPES,grant()are untouched.4. Failure direction — fails closed in every case I could name
null/undefined/''/[]/ whitespaceit.each, 5 cases)req_3: []→ 0 grants (probe 2)multiple: true['u_a','u_b']→ 2 grants (probe 2)text, lookup to another object)not-user-typedno-such-fieldINVALID_FIELD)read-failedgrantsRevoked: 1)userrecipientAn over-expansion path I looked for and did not find: no match-all principal, no owner fallback (pinned:
owner_idasserted absent), no cartesian product on the whole-rule pass (pinned + measured), no[]on a rule-wide caller that would mass-revoke (throws instead; pinned).5. Tenancy — the expansion respects the record's organization; the wall is not the claim
criteriaContext(rule)(tenantId = rule.organization_id, or unscoped for a platform-global rule), so an org-scoped rule cannot match — or read a column off — a sibling organization's record, and the grant lands in the organization whose row was read (resolveSystemGrantOrganization; a global rule's grant takes the record's own org). Measured in code; unchanged from finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119/finding: every sys_record_share grant row lands organization_id NULL — SharingService writes under a bare system context and the row literal never carries the column #14484.userrecipient (return [rule.recipient_id]). The write-time reference probe (objectqlassertReferencesResolve→referenceExists) is existence-only and deliberately unscoped, so a record editor can store a sibling-org user id in ausercolumn. What that yields is asys_record_sharerow naming a cross-org recipient — wrong at rest, the finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119 bloat class — not a read:buildReadFilterOR-s the grant into Layer 1, butcomputeTenantLayer0Verdictis AND-composed first on every tenant-scoped object for every non-platform-admin caller (isolated→organization_id = <caller's active org>,group→ membership set, missing org → deny). The wall compares against the session's organization (a vetted fact), not against the column's value (the claim). So this is not the "wall compares against an unvetted claim" shape.6. Pins — they drive the behaviour (ablation on the PR head, restored to 0 diff lines afterwards)
Baseline:
field-recipient.test.ts57/57 green (52 + the 5 #14754-composition pins).recipientsNamedOnRecord→return [](feature off, both paths)case 'field'removed frommapRecipientTypedesiredGrantsForRule(apositionregression)expandRecipientanswers[]forfieldinstead of throwinggit checkoutboth filesThe tests drive
bindRuleHooksfor real (the re-materialisation section), assert grantees after the pass rather than restating the code, and thepositionpin is falsifiable by the exact regression it guards.7. Seeder path — nothing seeds that the runtime cannot enforce silently
A declared
{ type: 'field', value: 'notes' }wherenotesistextpasses the spec grammar and seeds; at runtime it grants nobody and warns once per rule per process (not-user-typed). Declared ≠ enforced there, but loud — the ADR-0078 trap is the silent no-op, and this is not silent. Conversely the Setup UI path (plain insert intosys_sharing_rule) can store afieldrow whoserecipient_idfails the grammar (bindRuleCriteriaGuardchecks criteria only); the runtime answersno-such-field+ warn. Both directions fail closed and loud. See note 3.8. Measured end to end on the real
SqlDriver(better-sqlite3:memory:) + realObjectQLA scratch probe (not part of the PR) with
assignees: { type: 'user', multiple: true }, records written through the engine:engine.find(…, { fields: ['id','assignees'] })returns["u_a","u_b"]as an array — the PR's "drivers hydrate multi-value columns" claim holds on SQLite (formatOutputJSON.parsesjsonFields, andisJsonFieldincludesfield.multiple). PG/MySQL not measured here; the driver documents native json parse for both.evaluateRule→matchedRecords 3, expandedUsers 3, grantsCreated 3, grantsRefused 0; shares exactlyreq_1::u_a, req_1::u_b, req_2::u_c; no warns.ql.update(req_1, { assignees: ['u_b','u_z'] })thenevaluateAllForRecord→grantsCreated 1, grantsRevoked 1;u_agone,u_zpresent.getSchemashape (fieldskeyed by name) is whatfieldRecipientColumnIsUsablereads: atextcolumn → 0 grants +not-user-typed; alookuptosys_user→ read (req_1::u_rev).9. State of the PR (read from GitHub and git, not the report)
Draft; sole gate label
needs:contract-review;Fixes #15072first line; basemain; 15 files; changeset@objectstack/plugin-sharingminor (correct: a newly enforced recipient on a released package);content/docs/releases/**untouched; CI onfe9281910: 32 success / 2 skipped / 0 failure.git merge-treeof the head onto today'sorigin/mainceb487765: clean; the driver flagscontent/docs/permissions/system-context.mdxas generated → runpnpm gen:system-context-censuson the merged tree before landing (three files overlap with main's movement since the merge-base: that page,security.mdx,engine-double-contract.pinned.json— all merged without conflict).Non-blocking notes (none reopens the contract)
33 files / 853 tests; the head is 34 / 868 (dev report 5537276965). Refresh before undrafting.fieldcolumn cannot read the record. The wall holds by construction today (§5) but the guarantee lives inplugin-security, and a pin inplugin-sharingis what would notice a Layer 0 regression from this side.fieldrule whose column is not user-typed at seed time (the door the spec parse cannot see). Today's runtime warn is loud, so this is a strengthening, not a gap.sharing.zod.tsdocblock sentences go false when #15072 lands — thefieldrecipient stops being "not yet authorable" #15239 is over-scoped by one line: it listssharing.zod.ts:110as a third "until it lands" sentence. Onorigin/main:110is thequeuesentence ("deliberately NOT authorable until the implementation lands") — true and unrelated tofield. Only:91–92and:262are this PR's leftovers. Correct on that card before it dispatches.Verdict: PASS. The change is the ruled scope and only the ruled scope; the precondition was discharged before implementation and no second trigger exists; every accept-set move is declared and fails closed; the pins drive the behaviour (ablation: 28/1/1/1 red); and the runtime claims the fake engine could not test hold on the real driver.
hotlong commentedon Sep 5, 2026
Run after clearing, as the landing discipline requires:
What actually happened, from the label history rather than from memory: the gate was hung on one carrier — PR #15235 — and never on this card. The rule (maintainer 2026-08-22, 「两边都挂好」) makes it a dual carrier hung in one stroke and cleared in one stroke, and the second carrier exists for exactly one reason, in the checker's own words: it is "the only machine-readable evidence that a gate was CLEARED rather than STRIPPED — a legitimate clear leaves two removals seconds apart, a strip leaves one". With the hang single-carrier, my clear could only ever leave one removal. ⇒ The pair now reads as a strip because of how it was hung, not because of how it was cleared.
⛔ What this seat will not do about it. It will not re-hang the label on this card and clear both in one stroke. That would manufacture an event stream asserting a dual-carrier gate was cleared, when the truth is that a single-carrier gate was hung on 2026-09-04 and cleared today. Fabricating the evidence a checker looks for is worse than the gap the checker found.
What exists instead, and it is the human half the checker says it will not read: the tier-verified PASS is on this card (comment 5553380749) — a context-isolated
claude-fable-5-1reviewer under an adversarial brief, its transcript grepped before adoption at 113 stamps, all fable, zero non-fable, adopted verbatim. A human reading this card can distinguish "cleared" from "stripped" in one scroll; the machine cannot, and that is the defect being recorded.Landing decision, stated so it is auditable rather than implied: this seat proceeds to land, on the maintainer's instruction 「那你直接帮我跟进落地」 (2026-09-06), with checks ① and ③ genuinely green and ② red for the reason above.⚠️ Whoever audits later — including
check-half-states.mjsH35, which will see this pair — should read this comment and the PASS above it, and ⛔ should not read the single removal as a strip.Filed at source: the single-carrier hang is a discipline defect that will recur until the hanging step is what enforces the pair. Recorded as a finding rather than fixed here, because the fix belongs to whoever owns the gate's hanging path, not to a landing.
os-zhuang commentedon Sep 5, 2026
Contract review (clause ②) by the director seat — PASS — PR #15235 at head
fe928191; concurs with the adopted verdict 5553380749 and is written on the CARD carrier the single-carrier hang never reachedDirector seat, summon #15,
session_01TezFG8ZMrNH6n5VTNpPpdH(os-zhuang), 2026-09-05T22:2xZ, on the maintainer's instruction 「#15072 / PR #15235 我转交给其他人了,你执行契约复审」 — landing stays with the takeover seat (hotlong, 5553285937); this seat reviews only. Tier fuse:get_sessionthis session readssession_context.model=last_served_model=CONTRACT_REVIEW_TIER. This is an independent read of the diff (1bc3c092..fe928191, 15 files) and oforigin/main2e357650; the adopted subagent verdict was read afterwards as a cross-check. Where the two agree it is stated once; where this seat adds or differs it is marked.Implemented-by:
session_01XpTx2tbq3pZRYAdoGt6E6Y(dev reports 5536833453, 5537276965; branchclaude/issue-15072-field-sharing-recipient)Reviewed-by:
session_01TezFG8ZMrNH6n5VTNpPpdH(this comment) · prior: isolatedclaude-fable-5-1subagent adopted by6679d191-11f4-465b-b322-0e0409d76793(5553380749)① Derived judgments — ruling B (5507512776) against the diff
field; ⛔ nomanagermapRecipientTypegains exactlycase 'field';sys_sharing_rule.recipient_type5 → 6 values (pinned as a superset ofShareRecipientType.options);defineRulerefuses afieldrecipientIdoutside/^[a-z_][a-z0-9_]*$/— identical to the spec's inlineFieldSchema.nameregex (field.zod.ts:932), so a dotted graph walk (owner.manager_id) cannot reach the executor from REST / programmatic authoring.managerappears only as the refused control.fieldonlyexpandRecipientForRecord:recipient_type !== 'field'→ the unchanged rule-wideexpandRecipient(rule, pass)(memoised);field→recipientsNamedOnRecord(rule, row).desiredGrantsForRulemakes the same split on the whole-rule pass: rule-wide kinds = matched ids × one expansion (the old product, unchanged);field= (record, user) pairs off the projected rows, no product.positionrule-wide is pinned three ways (onesys_user_positionread across three matched records; the field rule on the same object reads no principal table; per-record pass too).rule-hooks.tsis not in the diff. Onorigin/main2e357650theafterUpdateregistration (rule-hooks.ts:260) runsrecomputeRow(id)for every affected id with no changed-field gating (:269). Pinned by driving the realbindRuleHooksand by asserting the bound hook set is the existing five.usersNamedBy: string or number ids, trimmed, de-duplicated;null/''/[]/ whitespace / object → nobody; a string is one id, never JSON to parse (drivers hydrate). Pinnedit.each×5 +owner_idasserted absent from grantees + revoke on emptied column.fieldRecipientColumnIsUsable:usertype, orlookup/master_detailwithreference: 'sys_user'→ read; declared-but-other-type or undeclared → nobody + one warn per rule per cause; nogetSchema/ nofields→ read on declared semantics (absence of evidence). AnINVALID_FIELDread refusal → no match, stale grants revoked, warnread-failed. All pinned.expandRecipientthrows forfield(never[])Error. Rationale holds —[]on a rule-wide caller is how "per record" becomes "revoke everything". All three callers are routed (per-record →expandRecipientForRecord; whole-rule →desiredGrantsForRule; recipient-axis revoke declinesfieldat its door,return 0), pinned by a spy that no production pass reaches the throw.catchin both reconcile loops)reconcile(rule, desiredSet)keeps #14930'sgrantOrAbsorbOrganizationRefusaland per-grant loop; thefieldthrow is code-less so the narrow catch cannot absorb it; five composition pins (refused pair counted, others land, stale row revoked; unrelated engine error still aborts). Conflict resolved by composition, not by taking a side.matchRecordprojects the recipient column besideidforfieldonly;findMatchingRowslikewise on the whole-rule pass['id'].content/docs/permissions/system-context.mdx(os-regen census anchors) regenerated bygen:system-context-census;scripts/engine-double-contract.pinned.json+2 entries via--write; fourtranslations/*.objects.generated.tsviacheck-i18n-bundles.mjs --write(zh-CN / ja-JP / es-ES hand-translated). Three hand-written docs pages drop their "until #15072 lands" sentences; the two/three inpackages/specare correctly left to the spec lane (#15239).check-governed-merges --teston the 15 paths: 0 hits.② semver
@objectstack/plugin-sharingminor — a newly enforced recipient kind on a released package. Correct. No@objectstack/specchange (the spec half landed under #14103).③ Boundary flags
sharing.zod.tsdocblock sentences go false when #15072 lands — thefieldrecipient stops being "not yet authorable" #15239 / [finding] the dogfood authz-conformance matrix'ssharing-rulesrecipient summary goes stale when #15072 lands — and no gate reads it #15240 withBlocked-by: #15072. Concur. Correction to carry to spec: threesharing.zod.tsdocblock sentences go false when #15072 lands — thefieldrecipient stops being "not yet authorable" #15239 (the adopted verdict's note 4, verified here onorigin/main):sharing.zod.ts:110is thequeuesentence and is not this PR's leftover — only:91–92and:262are; trim that card before it dispatches.fieldvalue is already a name); this card lands first on the 17.x line. The 2026-09-04 pause was a timing pause, lifted by the takeover.fieldcolumn as a follow-up; optional seed-time user-typed check; provenance). None reopens the contract.Evidence
Real commands with verdict lines at head
fe928191(34 files / 868 tests,check:test-typecheck OK, 80 derived gates green except the declared NOT-MEASUREDcheck:dual-build-cjs-loads); CI onfe928191now 37 success / 4 skipped / 0 red;mergeable_state: cleanagainstorigin/main2e357650(one plugin-sharing commit since base, #15996, no textual overlap).On the carrier evidence (for H35 / whoever audits)
The gate was hung on the PR only (2026-09-04) — the defect hotlong filed as #15975. The PR label came off at 17:02:59Z citing 5553380749; this card never carried it, so there is no second removal to read. This comment is the tier PASS on the card carrier, so both carriers now hold a human-readable review record even though the machine pair reads single-removal. No label is written here: re-hanging and clearing to manufacture a pair would be evidence fabrication, as 5553388622 says.
Landing (not this seat's)
The 17:06Z enqueue was kicked at 17:30Z by main-red on the merge-driver gate (#15994,
check-regen-pending --self-test), since closed — not by this PR. Re-enqueue is the takeover seat's; on the merged tree runpnpm gen:system-context-census(os-regen path) before the queue takes it, per landing-operations §A.Verdict: PASS.
Generated by Claude Code
hotlong commentedon Sep 5, 2026
Landed. PR #15235 merged 2026-09-05T23:23:03Z; merge commit
9fa577535, confirmed onorigin/main— "feat(plugin-sharing): expand thefieldsharing recipient per matched record — the services half of the #14103 ruling".Shipped as ruled (#14103, ruling B): one new member;
expandRecipientbecomes per-record forfieldonly; ⛔ nomanager; andpositionstill expands rule-wide, pinned by a test that reds on exactly that regression.How it got here, since the path was not straight
claude-opus-5, ⛔ notCONTRACT_REVIEW_TIER, so it did not self-clear: a context-isolated fable reviewer was fed only this card, Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103 and the PR — ⛔ never this seat's conclusions — under an adversarial brief. Transcript verified before adoption: 113 harness-stampedmodelfields, allclaude-fable-5-1, zero non-fable. Verdict PASS, adopted verbatim (5553380749). The reviewer drove its own ablation — feature off ⇒ 28 red, seeder case removed ⇒ 1, rule-wide members made per-record ⇒ 1 (the ruling's own pin), rule-widefieldreturning[]instead of throwing ⇒ 1 — and re-measured the ruling's hard precondition on today's tree rather than trusting the report.needs:contract-reviewhang makes the eventual clear indistinguishable from a strip — the defect is committed at hang time and only surfaces at landing #15975.mainwas red on the merge-driver gate (17:30Z).aa6ba0623and was wrong — the same SHA passed that gate at 17:21Z and failed at 17:26Z; the real cause was environmental (an unpinned fixture resolvingpnpm@latest, which had just become12.3.4and rejects-s), fixed by PR fix(devx): pin the regen self-test fixture to the root's packageManager so Corepack never resolves pnpm latest (#15992) #16002. My duplicate card main is RED on the merge-driver gate —check-regen-pending --self-testfails since aa6ba0623, and the queue kicks every PR while it stands #15994 was closed against the existingcheck-regen-pending --self-testruns its gate stubs through ambientpnpmin a temp dir with nopackageManager, so a launcher that cannot resolve there reds an innocent PR — reproduced 2/2 onorigin/mainand on an unrelated branch #15990/main red:Lint & Repo Gates› Merge-driver wiring gate —check-regen-pending.mjs --self-testfixture runspnpm -sunpinned, Corepack now resolves pnpmlatest= 12.3.4 which rejects-s; merge queue blocked since 17:05Z #15992 line. Two lessons, both mine: cut the failure boundary by time before cutting it by commit — same SHA disagreeing across lanes is environmental, and no diff needs reading; and ⛔ urgency does not exempt a dedupe search, least of all on main-red, which everyone discovers at once.Non-blocking notes from the review, carried rather than dropped
The PR body's stale test counts were refreshed (33/853 → 34/868). Three remain for their owners: a walled-posture e2e that a sibling-organization user named in a⚠️ Correct that before #15239 dispatches.
fieldcolumn cannot read the record (the wall holds by construction, but the pin belongs on theplugin-sharingside); optional seed-time hardening for afieldrule whose column is not user-typed; and #15239 is over-scoped by one line —sharing.zod.ts:110is the unrelatedqueuesentence, so only:91–92and:262are this PR's leftovers.pm:dispatchedstripped in the same stroke as the close;domain:services,security,priority:p2retained.docs(spec): two docblocks that promised a future which has already ar…
docs(spec): two docblocks that promised a future which has already ar…
fix(plugin-sharing): recipient_id declares object_name, the second si…