feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) - #15084
Conversation
…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
…are-recipient-field
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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
|
Landing provenance (PM seat
Generated by Claude Code |
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. Labelledneeds:contract-reviewon 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)
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.ts—ShareRecipientTypegains'field'. The enum docblock states the ruled semantics (per-record; the user or users named by a user-typed column on the record;multiple: truehonoured; empty column shares with nobody, fail-closed; re-materialises when the record's own write changes that column; executor is plugin-sharing: expand the ruledfieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072; until it lands the declared-rule bootstrap skips afieldrule with a logged warning) and the ⛔ on amanagermember with the director's reasoning (no graph walk, no graph-change re-materialisation obligation — the reason theownerrecipient was once removed; the recipient stays visible on the record). ThesharedWithdescribe and thevaluedescribe now read as "the principal id or code, or fortype: 'field'the name of a user-typed field on the record". Vocabulary mirrorsapproval.zod.ts'sApproverType'field'("User ID defined in a record field") — same word, same meaning.field-scoped refinement onvalue(superRefine, zod v4 — the object stays a plainZodObject, so every walker and generator sees the same shape): whentype === 'field',valuemust be a snake_case field name (/^[a-z_][a-z0-9_]*$/, theFieldSchema.namegrammar). Measured before this change:valuecarried 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— themanagermember by another spelling, a graph walk as a value) are refused at parse withcode: 'custom',path: ['sharedWith', 'value']and a message naming the remedy.packages/spec/src/contracts/sharing-service.ts—SharingRuleRecipientType(the stored-rowrecipient_typeunion) gains'field', and its docblock is rewritten: the old text listeddepartmentandrole, neither of which is in the union. Measured (H4):plugin-sharing/src/bootstrap-declared-sharing-rules.tsmapRecipientTypemapssharedWith.typeonto 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 incontracts/sharing-service.test.tsasserts, at the type level (anAssertofEqbetween the stored-row union minusqueueand 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; themultiple: trueshape pin (aFieldSchema{ 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 forsharedWith: { type: 'manager', value: 'x' }assertingcode: 'invalid_value',path: ['sharedWith', 'type']and the first sentenceInvalid 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-valuerefusal; the dotted-path refusal; a preservation pin that the other members'valueis unchanged.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.jsonuntouched, never hand-edited). Re-check:✓ All 15 generated artifacts are up to date.content/docs/permissions/sharing-rules.mdx(recipient table row) andcontent/docs/protocol/objectql/security.mdx(YAML comment, afieldparagraph, 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 ruledfieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072; 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:
packages/spec/src/contracts/sharing-service.test.ts—expect(ShareRecipientType.options).toEqual([...]), flipped to include'field'.packages/lint/src/validate-org-axis-red-lines.test.ts— "partitionsShareRecipientType— no recipient is unaccounted for (ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991)": assertsBU_TREE_RECIPIENTS ∪ FLAT_RECIPIENTSequalsShareRecipientType.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 markedpackages/lint/**⛔ because Thesharing-rule-runtime-variable-conditionfix-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_RECIPIENTSgains'field'in the test, and the red-line table docblock invalidate-org-axis-red-lines.tsgains thefieldrow ("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: afieldrecipient 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.tsreadssharedWithstructurally and does not enumerate the enum.Hypotheses H1, H2, H4 — verified
17.sharing-rule-recipient-reconcile.tsis a semantic entry (no transform) about the droppedgroup/guest/ owner-type shapes, and the registry entry onsharingRule.sharedWith.typeis therole → positionrename; 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-guidegreen).check:authorable-surfaceaccepted the member as an addition;authorable-surface.base.jsonwas not rewritten (gen:schemanever ran in a MERGE state — the merge oforigin/mainwent throughscripts/pm/os-regen-merge.sh, merge committed first, regeneration after, and it produced no diff).Liveness
check:livenessgoverns a fixedGOVERNEDlist of metadata types; no sharing-rule type is on it (or inPENDING_GOVERNANCE), and an enum member is not a property in its census. The gate ran green withliveness/state-counts.mdunchanged and required nothing — there is no ledger to hold aplannedentry 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
expandRecipientbecomes per-record fortype: 'field'only: for each record the rule's criteria match, read the user-typed column named byrecipient_idon that record and expand to the user id(s) it holds. Every other member —user,team,position,business_unit,unit_and_subordinates— keeps expanding once per rule; one pin that apositionrecipient still expands rule-wide (ruling).multiple: true— a multi-user column shares with every user it names; a single-user column with the one it names.ownerrecipient type, and confirm on the tree that criteria-rule materialisation re-runs on a record update that changes the recipient field — if it does not, stop and report the fork rather than adding a second materialisation trigger.bootstrap-declared-sharing-rules.tsmapRecipientTypegainscase 'field': return 'field'; thesys_sharing_rule.recipient_typeField.selectinsys-sharing-rule.object.tsgains'field'(today it lists five values, so a storedfieldrow would be refused at the select until it is widened); therecipient_idpicker description says the value is a field name for that type.sharing.zod.tsand the enforcement-status note incontent/docs/protocol/objectql/security.mdxcarry an "until plugin-sharing: expand the ruledfieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072 lands" sentence each — the services half deletes both.managermember; 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 throughscripts/pm/os-verify-lock.sh, slotissue-14103; numbers are shared-box readings). Build closure:@objectstack/spechas no workspace dependencies, sopnpm --filter '@objectstack/spec^...' buildis empty;pnpm --filter @objectstack/spec build→check-dts-emitted: 34/34 declared declaration file(s) present.pnpm --filter @objectstack/spec build→check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.(run before and again after theorigin/mainmerge).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.jsonincludessrc/**, so both edited test files are in the compiled set)....@objectstack/specnarrowed to the two packages whose sources name the widened union / enum):pnpm turbo run typecheck --filter=@objectstack/lint --filter=@objectstack/plugin-sharing --concurrency=2→Tasks: 17 successful, 17 total, bothcheck:test-typecheck: OK.plugin-sharing'sexpandRecipientis anifchain with no exhaustiveness assertion, so the widened union compiles unchanged there.@objectstack/specsuite in two halves (foreground cap):vitest run --maxWorkers=3 src→Test Files 415 passed (415) · Tests 11379 passed (11379);vitest run --maxWorkers=3 scripts→Test Files 50 passed (50) · Tests 1025 passed (1025);packages/lintsrc/validate-org-axis-red-lines.test.ts→Tests 38 passed (38).node scripts/pm/dispatch-gates.mjsderived from the actual diff (no paths passed, run from inside the worktree), each exit captured by redirection before any pipe, allEXIT=0: speccheck: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 withPREREQUISITE NOT MET, not a finding); lintcheck:doc-security-posture,check:doc-formula-expressions(measured after the closures were built; first runs exit 3PREREQUISITE NOT MET); repocheck: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 jsonover the changed.tsfiles — a measured narrowing ofpnpm lint, three pieces of evidence: ① population from eslint's own config (--print-configresolves a config for every changed.tsfile — none ignored); ② the JSON output lists 7 files (my 6 pluspackages/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 noparserOptions.projectand no typed rules, so an edit in one file cannot move any untouched file's verdict.check:dual-build-cjs-loads(Build Core, needs the full workspace build) andcheck:react-declaration-parity(needs objectui's manifest —check:generatednames it un-runnable here).Reverse verification (ablation)
Prediction, stated before running: accept pins RED; the
managerrefusal GREEN; thefield-scopedvaluerefusal 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 ofShareRecipientType(perl -0pi), proven on disk by marker count^ 'field',$1 → 0 and blob hashc31b6d68…→d22d8c5a…(non-empty, differs). Mutated build: the tsup JS pass emitteddist/, then the.d.tspass failed —sharing.zod.ts(205,9): error TS2367: … '"field"' have no overlapat the refinement'srecipient.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✗ treeline in that mode is its restore-leg tree check reporting the still-mutated source — expected mid-leg). The lint pin resolves@objectstack/specthroughexports(dist — it is inKNOWN_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 afieldrecipient naming a user-typed field · accepts the same shape for amultiple: trueuser field · positive control · refuses afieldrecipient whosevaluenames 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 reservedqueue; green:refuses type: 'manager' by nameand the preservation pin. LintTests 2 failed | 36 passed (38)— red:partitions ShareRecipientType — no recipient is unaccounted for (#4991)andallows the flat field audience on a platform-global object(theit.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; anEXIT INT TERMtrap carried the same command for the crash path); proven by blob hash equal to the HEAD blob (c31b6d68…),git diff HEAD0 lines, marker count back to 1; restored build exit 0; preflight (present mode)✓ dist/: marker present in 18 built filesand✓ tree: working tree clean against HEAD;git status --porcelainempty; pins re-run: spec53 passed (53), lint38 passed (38).Patch round (2026-09-03, after contract review PASS · ACCEPT; open question ruled A)
$-backtick sequence in the ablation paragraph expanded underString.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.Claim:comment on the card (5532187916) with the full file surface including the twopackages/lintfiles andClause-②: yeson its own line.content/docs/permissions/permissions-matrix.mdxenforcement-status sentence rewritten to the same truth as thesecurity.mdxnote (thefieldrecipient parses; until plugin-sharing: expand the ruledfieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072 lands the declared-rule bootstrap skips such a rule with a logged warning — never silently, never wider). Commitf3e5b1b8(docs only; no schema edits), pushed without rebase or force.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-countsallEXIT=0;node scripts/check-system-context-census.mjs→OK — 106 elevation read sites in 20 packages across 45 files, all anchored(no rot, no--fix).node scripts/pm/check-clause2-carriers.mjs --pair 15084first exited 4 (C3: the review had clearedneeds:contract-reviewfrom 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-hungneeds:contract-reviewon 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 one849a0afstands 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