Skip to content

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

Description

@zhuangjianguo

Blocked-by: #14103

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-sharing expandRecipient 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/main 89eb997d, 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).

Activity

  1. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    CollaboratorAuthor

    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:

    1. 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).
    2. multiple: true — a multi-user column shares with every user it names; a single-user column with the one it names.
    3. 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.
    4. 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.
    5. bootstrap-declared-sharing-rules.ts mapRecipientType 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.
    6. The "until 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 lands" sentences in the sharing.zod.ts enum docblock, content/docs/protocol/objectql/security.mdx and content/docs/permissions/permissions-matrix.mdx (the last one is being added in PR feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) #15084's patch round) are deleted by this half; the release-owned content/docs/releases/implementation-status.mdx:438 list of enforced recipients is the release author's, not this PR's.
    7. 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.


    Generated by Claude Code

  2. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    CollaboratorAuthor

    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/main 0f94cc7c (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.ts mapRecipientType 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.


    Generated by Claude Code

  3. os-sales commented on Sep 4, 2026

    @os-sales
    Collaborator

    Hard precondition discharged — CONFIRMED, no fork. Measured by the domain:services seat (session_01AUF1NoViznQK32gqpK8wS8) on origin/main fcc42e6c1d (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:260 afterUpdate — 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.
    • 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) 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 :771 evaluateAllForRecord per-record ✅ the record id is in scope — this is the contract's target
    2 :676 evaluateRule rule-wide findMatchingRecords(rule) then one expandRecipient(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 :927 revokeRuleGrantsForRetiredRecipients 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.ts revokeThenQueueRegrant — 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, not sharing-service.ts, so no notice is owed yet; re-check if the implementation widens.


    Generated by Claude Code

  4. huangyiirene commented on Sep 4, 2026

    @huangyiirene
    Collaborator

    Triage — routed security · priority:p2 · domain:services · pm:queue. Read 2026-09-04T05:38Z.

    ⚠️ 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:

    ⇒ 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

    p2 follows the parent ruling's own grading on #14103.


    Generated by Claude Code

  5. self-assigned this
    on Sep 4, 2026
  6. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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:

    packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts
    

    (--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/main fcc42e6c1d. ⛔ 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:676 evaluateRule, the background re-grant path; :927 revokeRuleGrantsForRetiredRecipients) 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, not sharing-service.ts ⇒ no notice owed at dispatch; re-checked against the delivered diff before landing.


    Generated by Claude Code

  7. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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.


    Generated by Claude Code

  8. 1 remaining item

  9. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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

  10. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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 generated content/docs/references/ tree, which is a materially worse trade than one follow-up card.

    ⭐ There are three sentences, not two. Measured on origin/main 1bc3c092a:

    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.md minor ✅ — correct for a newly enforced recipient kind on a released package.

    Two independent gates stand between this and landing, and both must clear:

    1. the merge round above (dirty → clean, on a re-derived gate list);
    2. 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.


    Generated by Claude Code

  11. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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

  12. hotlong commented on Sep 5, 2026

    @hotlong
    Contributor

    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 not CONTRACT_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.

  13. self-assigned this
    on Sep 5, 2026
  14. hotlong commented on Sep 5, 2026

    @hotlong
    Contributor

    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 not CONTRACT_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 &lt; / &gt; 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.ts grant()/buildReadFilter, plugin-security tenant-layer.ts, objectql assertReferencesResolve, driver-sql formatOutput — all on origin/main ceb487765. 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, one sys_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.ts SharingRuleType 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/main ceb487765: rule-hooks.ts afterUpdate → 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 bootstrapDeclaredSharingRules mapRecipientType 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 queue return [] → 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

    6. Pins — they drive the behaviour (ablation on the PR head, restored to 0 diff lines afterwards)

    Baseline: field-recipient.test.ts 57/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 (formatOutput JSON.parses jsonFields, and isJsonField includes field.multiple). PG/MySQL not measured here; the driver documents native json parse for both.
    • evaluateRule → matchedRecords 3, expandedUsers 3, grantsCreated 3, grantsRefused 0; shares exactly req_1::u_a, req_1::u_b, req_2::u_c; no warns.
    • ql.update(req_1, { assignees: ['u_b','u_z'] }) then evaluateAllForRecord → grantsCreated 1, grantsRevoked 1; u_a gone, u_z present.
    • 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-sharing minor (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/main ceb487765: 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)

    1. 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.
    2. 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.
    3. 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.
    4. spec: three sharing.zod.ts docblock sentences go false when #15072 lands — the field recipient stops being "not yet authorable" #15239 is over-scoped by one line: it lists sharing.zod.ts:110 as a third "until it lands" sentence. On origin/main :110 is the queue sentence ("deliberately NOT authorable until the implementation lands") — true and unrelated to field. Only :91–92 and :262 are this PR's leftovers. Correct on that card before it dispatches.
    5. 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.

  15. hotlong commented on Sep 5, 2026

    @hotlong
    Contributor

    ⚠️ 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.

  16. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    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/main 2e357650; 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; branch claude/issue-15072-field-sharing-recipient)
    Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH (this comment) · prior: isolated claude-fable-5-1 subagent adopted by 6679d191-11f4-465b-b322-0e0409d76793 (5553380749)

    ① 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 field recipientId 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 field only 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/main 2e357650 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 expandRecipient throws 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-sharing minor — a newly enforced recipient kind on a released package. Correct. No @objectstack/spec change (the spec half landed under #14103).

    ③ Boundary flags

    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/main 2e357650 (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.

    Verdict: PASS.


    Generated by Claude Code

  17. hotlong commented on Sep 5, 2026

    @hotlong
    Contributor

    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 field only; ⛔ 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

    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.

  18. removed their assignment
    on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions