feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field
Sep 3, 2026
Merged

feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103)#15084
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14103-share-recipient-field

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14103

Clause ②: yes — path limb (packages/spec/src/security/sharing.zod.ts); content limb yes: new member on a published enum / accept-set widening. Labelled needs:contract-review on this PR and on the card in the same stroke. Draft on purpose — do not flip to ready.

Ruling (maintainer 2026-09-02, director summon #8, verbatim reply 「同意」; card comment 5507512776)

Ruled: B.ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER_FIELD_NAME' } (the ruling spells that placeholder in angle brackets; transcribed as a word because the platform sanitizer eats angle-bracket fragments) 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. "Share with the owner's manager" is authored as a user field on the record (populated by the application — snapshot or kept-in-sync is the application's explicit choice) plus a field recipient. Option C (prose + diagnostic only) is not taken; #14234 (the misleading lint hint) proceeds regardless.

Execution, contract-first: domain:spec lane lands the ShareRecipientType member and its description first (Clause-②: yes, CONTRACT_REVIEW_TIER); 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.

This PR is the spec half only. The services half is #15072 (Blocked-by: #14103); it is not addressed here.

What lands

  • packages/spec/src/security/sharing.zod.tsShareRecipientType gains 'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record; multiple: true honoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is 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; until it lands the declared-rule bootstrap skips a field rule with a logged warning) and the ⛔ on a manager member with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason the owner recipient was once removed; the recipient stays visible on the record). The sharedWith describe and the value describe now read as "the principal id or code, or for type: 'field' the name of a user-typed field on the record". Vocabulary mirrors approval.zod.ts's ApproverType'field' ("User ID defined in a record field") — same word, same meaning.
  • A field-scoped refinement on value (superRefine, zod v4 — the object stays a plain ZodObject, so every walker and generator sees the same shape): when type === 'field', value must be a snake_case field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar). Measured before this change: value carried no constraint at all{ type: 'user', value: '' } parses today and still does. A global .min(1) would have been an accept-set narrowing on the existing members, so the refusal is scoped to the new member, where nothing parsed before: an empty name (a rule naming no column would be skipped at seed — silent under-share, ADR-0078) and a dotted path (owner.manager_id — the manager member by another spelling, a graph walk as a value) are refused at parse with code: 'custom', path: ['sharedWith', 'value'] and a message naming the remedy.
  • packages/spec/src/contracts/sharing-service.tsSharingRuleRecipientType (the stored-row recipient_type union) gains 'field', and its docblock is rewritten: the old text listed department and role, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientType maps sharedWith.type onto this union by an explicit case table, one case per authorable member, default: return null (skipped with a warning, never widened) — so the stored row mirrors the authoring enum member-for-member and the union belongs in step. A new pin in contracts/sharing-service.test.ts asserts, at the type level (an Assert of Eq between the stored-row union minus queue and the enum's option type) and at the value level, that the two lists stay in step.
  • packages/spec/src/security/sharing.test.ts — accept pin { type: 'field', value: 'assignees' } on a criteria rule; the multiple: true shape pin (a FieldSchema{ type: 'user', multiple: true } column and a rule naming it both parse — the schema pins the shape, the executor the fan-out); the refusal pin for sharedWith: { type: 'manager', value: 'x' } asserting code: 'invalid_value', path: ['sharedWith', 'type'] and the first sentence Invalid option: expected one of … (independent of the accept set by design, so the ablation below reds the accept pins and leaves it green); a positive control (type: 'field', value: 'manager_user' parses); the empty-value refusal; the dotted-path refusal; a preservation pin that the other members' value is unchanged.
  • Generated via pnpm --filter @objectstack/spec check:generated --fix: exactly one artifact was stale — content/docs/references/security/sharing.mdx (JSON schema / authorable surface / api-surface / export-origins / declaration-map all read — the member is an addition; authorable-surface.base.json untouched, never hand-edited). Re-check: ✓ All 15 generated artifacts are up to date.
  • Hand-written docs that enumerate the recipient vocabulary: content/docs/permissions/sharing-rules.mdx (recipient table row) and content/docs/protocol/objectql/security.mdx (YAML comment, a field paragraph, and the enforcement-status note — it used to say "every authorable recipient type is enforced", which would be false between this PR and 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; it now says what is true).
  • .changeset/sharing-rule-field-recipient.md@objectstack/specminor (public-surface widening; no BREAKING banner: nothing that parses today stops parsing).

Falsified hypothesis H3 — two closed-list pins existed, both flipped (and one is in packages/lint)

The brief asked me to grep for a pin of the exact member list and flip it if found. Two exist:

  1. packages/spec/src/contracts/sharing-service.test.tsexpect(ShareRecipientType.options).toEqual([...]), flipped to include 'field'.
  2. packages/lint/src/validate-org-axis-red-lines.test.ts"partitions ShareRecipientType — no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": asserts BU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTS equals ShareRecipientType.options. Its own doc: "A sixth enum member added tomorrow fails HERE, at the vocabulary, instead of quietly inheriting whichever bucket nobody chose for it." The enum widening reds it unless the widener classifies the member. The brief marked packages/lint/** ⛔ because The sharing-rule-runtime-variable-condition fix-hint sends authors to RLS to widen a private object, but the layers are AND-composed — the advice cannot work on the case that most needs it #14234 landed its own lint-hint fix; this pin is a different file and mechanism, and H3's instruction ("flip it to the new list and say so") covers it, so I made the minimal classification edit and am declaring it: FLAT_RECIPIENTS gains 'field' in the test, and the red-line table docblock in validate-org-axis-red-lines.ts gains the field row ("read off the row itself, no tree walked, so no organization needed to resolve one in"). BU_TREE_RECIPIENT_TYPES — the only runtime input of rule ② — is unchanged, so lint behaviour is unchanged; no lint changeset. The classification follows from the ruling: a field recipient is a column on the record, not a business-unit tree. Reviewer: if you would rather this land in a lint-lane PR, say so and I will split it.

Other pins that enumerate the list are accept-only and were extended (sharing.test.ts "accepts exactly the authorable recipients" and the recipient matrix). examples/app-showcase/test/inert-wirings.test.ts reads sharedWith structurally and does not enumerate the enum.

Hypotheses H1, H2, H4 — verified

  • H117.sharing-rule-recipient-reconcile.ts is a semantic entry (no transform) about the dropped group / guest / owner-type shapes, and the registry entry on sharingRule.sharedWith.type is the role → position rename; neither enumerates the accept set. No conversion or migration entry added — a widening prescribes none, and no gate asked for one (check:spec-changes, check:upgrade-guide green).
  • H2check:authorable-surface accepted the member as an addition; authorable-surface.base.json was not rewritten (gen:schema never ran in a MERGE state — the merge of origin/main went through scripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).
  • H4 — verified as above (explicit 1:1 case table, fail-closed default).

Liveness

check:liveness governs a fixed GOVERNED list of metadata types; no sharing-rule type is on it (or in PENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green with liveness/state-counts.md unchanged and required nothing — there is no ledger to hold a planned entry for this member. The "consumer at landing" statement therefore lives where the gate would otherwise have put it: the enum docblock and the reference docs name #15072 as the executor and state the interim seed-time behaviour (skipped with a warning).

Executor contract the services half must honour (#15072) — written so the PM can lift it

  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 (ruling).
  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 / 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. Hard precondition from the ruling: 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.tsmapRecipientType gains case 'field': return 'field'; the sys_sharing_rule.recipient_typeField.select in sys-sharing-rule.object.ts gains 'field' (today it lists five values, so a stored field row would be refused at the select until it is widened); the recipient_id picker description says the value is a field name for that type.
  6. The enum docblock in sharing.zod.ts and the enforcement-status note in content/docs/protocol/objectql/security.mdx carry an "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" sentence each — the services half deletes both.
  7. Nothing here gives a manager member; the owner's manager is a user field the application stores on the record.

Verification record

Union run on the final commit e849a0af (all heavy commands through scripts/pm/os-verify-lock.sh, slot issue-14103; numbers are shared-box readings). Build closure: @objectstack/spec has no workspace dependencies, so pnpm --filter '@objectstack/spec^...' build is empty; pnpm --filter @objectstack/spec buildcheck-dts-emitted: 34/34 declared declaration file(s) present.

  • pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. (run before and again after the origin/main merge).
  • pnpm --filter @objectstack/spec check:generated --fix✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; the re-check and the post-merge check both read ✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec typecheck (tsc + check:scripts-typecheck + check:test-typecheck) → tsc clean; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held (ledger unchanged; tsconfig.test.json includes src/**, so both edited test files are in the compiled set).
  • Consumer direction (the prefix filter ...@objectstack/spec narrowed to the two packages whose sources name the widened union / enum): pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2Tasks: 17 successful, 17 total, both check:test-typecheck: OK. plugin-sharing's expandRecipient is an if chain with no exhaustiveness assertion, so the widened union compiles unchanged there.
  • Tests — the whole @objectstack/spec suite in two halves (foreground cap): vitest run --maxWorkers=3 srcTest Files 415 passed (415) · Tests 11379 passed (11379); vitest run --maxWorkers=3 scriptsTest Files 50 passed (50) · Tests 1025 passed (1025); packages/lintsrc/validate-org-axis-red-lines.test.tsTests 38 passed (38).
  • Gates — every family node scripts/pm/dispatch-gates.mjs derived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, all EXIT=0: spec check:api-surface ("public API surface + factory signatures unchanged ✓"), check:authorable-surface ("Successfully generated 1600 schemas"), check:docs ("229 generated files in sync"), check:liveness ("state-counts.md is current — the same 35 row(s)"), check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:empty-state, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:skill-examples ("256 prose examples type-check across 3 surface(s)" — measured after building @objectstack/client-react; its first run exited 1 with PREREQUISITE NOT MET, not a finding); lint check:doc-security-posture, check:doc-formula-expressions (measured after the closures were built; first runs exit 3 PREREQUISITE NOT MET); repo check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check:partof-closing-keyword, check:test-source-alias, check:cross-package-test-inputs, check:empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:doc-anchors ("298 internal fragment link(s) … all resolve"), check:doc-authoring, check:corpus-claim-drift, check:docs-single-h1, check:role-word, check:vendor-version-stamps, check:docs-redirects, check:docs-audit-scope, check:published-readme-links, check:quick-reference-counts, check:objectui-changeset, check:pm-half-states, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:objectql-double-limit, check:where-matcher, check:type-source-resolution, check:skill-identifier-liveness, check:react-page-adapter-contract, check-comment-mask-adoption, check-ci-filter-parity.
  • eslint --no-inline-config --format json over the changed .ts files — a measured narrowing of pnpm lint, three pieces of evidence: ① population from eslint's own config (--print-config resolves a config for every changed .ts file — none ignored); ② the JSON output lists 7 files (my 6 plus packages/spec/src/data/filter.zod.ts, which the two-dot range picked up from main's merge), 0 errors, 0 warnings, exit 0; ③ invariance: eslint.config.mjs (its own comment at line 328) runs no parserOptions.project and no typed rules, so an edit in one file cannot move any untouched file's verdict.
  • NOT MEASURED locally, CI owns them: check:dual-build-cjs-loads (Build Core, needs the full workspace build) and check:react-declaration-parity (needs objectui's manifest — check:generated names it un-runnable here).

Reverse verification (ablation)

Prediction, stated before running: accept pins RED; the manager refusal GREEN; the field-scoped value refusal pins RED (the member's absence pre-empts the refinement — those pin the new member's contract, not the old accept set); the preservation pin GREEN.

Mutation on HEAD e849a0af: deleted the 'field', line of ShareRecipientType (perl -0pi), proven on disk by marker count ^ 'field',$ 1 → 0 and blob hash c31b6d68…d22d8c5a… (non-empty, differs). Mutated build: the tsup JS pass emitted dist/, then the .d.ts pass failed — sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlap at the refinement's recipient.type !== 'field' guard — so the refinement is type-bound to the member (a red that is itself evidence). Dist proof: node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER --absent✓ dist/: marker absent from all 89 built files (MARKER is the two-line "business_unit", / "field" sequence the bundler emits for the enum; the preflight's ✗ tree line in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves @objectstack/spec through exports (dist — it is in KNOWN_UNALIASED_TEST_IMPORTS), which is why the rebuild and the dist proof are part of each leg.

Observed under mutation: spec Tests 9 failed | 44 passed (53) — red: accepts exactly the authorable recipients · should accept every enforced recipient type · accepts a field recipient naming a user-typed field · accepts the same shape for a multiple: true user field · positive control · refuses a field recipient whose value names no field · refuses a dotted path · security ShareRecipientType is the RULE vocabulary (contracts options pin) · the stored-row union is exactly the authoring enum plus the reserved queue; green: refuses type: 'manager' by name and the preservation pin. Lint Tests 2 failed | 36 passed (38) — red: partitions ShareRecipientType — no recipient is unaccounted for (#4991) and allows the flat field audience on a platform-global object (the it.each(FLAT_RECIPIENTS) accept case — one accept-side red I had not listed; same direction). Direction matched the prediction, plus that one extra accept-side red.

Restore: git checkout HEAD -- /home/user/objectstack-issue-14103/packages/spec/src/security/sharing.zod.ts (absolute path; an EXIT INT TERM trap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…), git diff HEAD 0 lines, marker count back to 1; restored build exit 0; preflight (present mode) ✓ dist/: marker present in 18 built files and ✓ tree: working tree clean against HEAD; git status --porcelain empty; pins re-run: spec 53 passed (53), lint 38 passed (38).

Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)

  • Body rewritten as one clean copy (the first version was a doubled splice — a $-backtick sequence in the ablation paragraph expanded under String.replace; rebuilt with function-form replacements and sent as a JSON file). Read-back: nine ## headings, each once; the first line is the one closing-keyword line for the card and the only one in the body; no angle-bracket fragments.
  • Late dev-seat Claim: comment on the card (5532187916) with the full file surface including the two packages/lint files and Clause-②: yes on its own line.
  • content/docs/permissions/permissions-matrix.mdx enforcement-status sentence rewritten to the same truth as the security.mdx note (the field recipient parses; 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 the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commit f3e5b1b8 (docs only; no schema edits), pushed without rebase or force.
  • On head f3e5b1b8: pnpm --filter @objectstack/spec build✓ packages/spec/dist/.build-input-hash; check:generated✓ All 15 generated artifacts are up to date.; check:docs✅ 229 generated files in sync with packages/spec; check:doc-authoring, check:corpus-claim-drift, check:doc-anchors (298 fragment links resolve), check:role-word, check:docs-single-h1, check:quick-reference-counts all EXIT=0; node scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchored (no rot, no --fix).
  • Clause-② carriers: node scripts/pm/check-clause2-carriers.mjs --pair 15084 first exited 4 (C3: the review had cleared needs:contract-review from both carriers at 21:09:22Z, and the docs commit above moved the head at 21:12:01Z, so the clearance covered a tree that would no longer land — the script names that the re-hang-owed state, a seat's act). Since my commit moved the head, I re-hung needs:contract-review on the card and the PR through the additive endpoint (nothing removed), read both back, and re-ran the check: exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree". The reviewer's PASS on e849a0af stands as the record; the delta since is the one documentation sentence above.

Out of scope / not touched

packages/plugins/plugin-sharing/** (read only — the services half), packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, content/docs/releases/**, skills/**, objectui. #15072 remains open; #14234 (its own lint-hint fix has already landed) is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ing recipient
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed
field on the matched record as its recipient; no manager member. Spec half only:
enum member + describes, field-scoped value refinement, stored-row union mirror,
pins, lint partition row, docs, changeset. Executor is the services half.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sal pin independent of the accept set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/deployment/validating-metadata.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/administrator-guide.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/index.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/permissions-matrix.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/positions.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/permissions/sharing-rules.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/protocol/objectql/security.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v13.mdx(via unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))
  • content/docs/releases/v17.mdx(via ShareRecipientType (symbol, a top-level const object), business_unit (literal, a string literal in SharingRuleRecipientType), unit_and_subordinates (literal, a string literal in SharingRuleRecipientType))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-org-axis-red-lines.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from de8673a9c3d4293d3994154bdc5a981451e06518 — the merge of head f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de8673a9c3d4293d3994154bdc5a981451e06518 && git checkout de8673a9c3d4293d3994154bdc5a981451e06518
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff f3e5b1b82a548ac24b52bf1cc3dc5f9540054ba7
node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…n the field recipient and its executor
The permissions matrix said every authorable recipient type is enforced; between
the field recipient landing and its per-record executor that is false. Say what
is true: it parses, and the declared-rule bootstrap skips it with a logged
warning until the executor lands — never silently, never wider.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:40Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:40
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0f94cc7Sep 3, 2026
53 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14103-share-recipient-field branch September 3, 2026 22:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude