Skip to content

feat(plugin-sharing,cli)!: declared sharing rules are read from the registry; the ownership ceremony deletes their seeded copies - #24376

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23952-declared-sharing-rules-registry
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23952-declared-sharing-rules-registry

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23952
Clause-②: no (narrowing)

C3 stage 9 of ADR-0131 (#15204), in the order ruling β (6216760903) sets. Declared sharing rules are read from the registry, and bootstrapDeclaredSharingRules retires. The ownership ceremony renames the grants of the copies that seeder wrote, then deletes the copies. DECLARED_SHARING_RULE_SEEDER_RUNS flips to false in this same change, as the #23948 hand-off (6220693269) orders. This is one PR, not 9a then 9b: 2,615 changed lines (+1,881 / -734, 28 files) after patch round 1, under the 3,000 line.

What changes

@objectstack/plugin-sharing: enforcement reads declared rules from the registry.

  • declared-sharing-rules.ts replaces bootstrap-declared-sharing-rules.ts. createDeclaredSharingRuleSource reads engine.registry.listItems('sharing_rule'), and the metadata service's list('sharing_rule') only when the registry holds no rule. That is the same two readers, in the same order, the seeder used. It translates each declaration into the runtime shape, exactly as the seeder did (ADR-0057 D6). Translation is memoised per definition object. A rule that is not served says so once per rule and cause per process.
  • SharingRuleService takes the source (declaredRules) and projects each declared rule into the scopes the plugin sets from the posture (setDeclaredRuleScopes / addDeclaredRuleScope). Under single there is one organization-less scope. Under a wall there is one scope per organization, plus an organization created after boot. These are the seeder's passes (The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103), as reads.
  • Every read of the rule set includes the projections: listRules (which the hooks, the object and boot reconciles, the business-unit recompute and the rebind all go through), getRule, evaluateRule and the orphaned-grant sweep.
  • A projection's id is its name, so its grants carry source_id = the rule's name (ruling β item 5 (c)). Under a wall each organization's projection reads and writes only its own grants: ruleGrantScope adds the grant's organization_id. An organization-authored rule keeps its row and its id-based source_id.
  • A stored row holding the declared name answers for it, and the declaration is not served beside it. Under single that is a row of that name in any organization, and under a wall that organization's row. This is the row the seeder's upsert would have found: an organization's own rule, a customized seeded copy, or a seeded copy the ceremony has not yet deleted. The seeder never overwrote admin-authored or customized rows, and this keeps that.
  • deleteRule on a declared rule answers PERMISSION_DENIED. It has no row, and is changed in code.
  • The boot no longer writes any sys_sharing_rule row for a declaration.

@objectstack/cli: the ceremony executes the declared-rule-mirrors departure.

  • DECLARED_SHARING_RULE_SEEDER_RUNS = false, so the mirrors hold certification again.
  • The mirror-deletion step ends with runDeclaredRuleMirrorDeletion, which works in the ruled order:
    1. It reads the mirrors with the departure's own selector (managed_by package or platform, customized not true), under every organization.
    2. It refuses, writing nothing, if grants still name a mirror whose rule no installed package declares.
    3. It renames every sys_record_share grant naming a mirror by row id to the rule's name.
    4. It verifies that no grant names a mirror.
    5. It deletes the mirrors by id, with the selector re-asserted, and counts them to zero.
  • The step runs again on a checkpointed ceremony while a mirror stands. That covers a database that completed the ceremony while the seeder ran (hotcrm's dev.db).
  • The declared names come from a new DeclaredSharingRulesPort. The command answers it from the booted registry, which is what enforcement reads.

@objectstack/lint. The sharing-rule enforceability verdict and os explain now say a declared rule whose condition does not lower is "never served", not "never seeded".

Pins flipped (the whole-repo grep for the seeder's behaviour):

  • organization-ownership.declared-rule-mirrors.integration.test.ts case 1: a served boot writes zero rows and the service serves every declared rule under its name. Case 2: eleven mirrors with grants are renamed and then deleted, and every grant resolves. The result is [complete, blocking, advisory] = [true, 0, 0] and the marker is verified. Then the guarded served boot serves the declared rules.
  • organization-ownership-apply.declared-rule-mirrors.test.ts, which also covers the checkpointed re-run, the undeclared-with-grants block, the grant-less undeclared delete, the no-registry block, the walled copy and the seeding-release advisory leg.
  • organization-ownership-apply.test.ts.
  • The remote-face case (skipped without sqld).
  • standalone-stack-seeder-declaration-copy.test.ts now reads the served rules, and sys_sharing_rule is empty.
  • The three dogfood files that read the seeded row.
  • The authz matrix row.
  • The sharing_rule liveness producer pointers.

Landing points outside the claimed file surface, and why

  • packages/spec/liveness/sharing_rule.json and packages/spec/scripts/liveness/proof-registry.mts. These are evidence pointers only: no schema, no type and no generated artifact. check:liveness resolves every path#Symbol producer anchor, and nine of them named bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules. Retiring the seeder without moving them reds that gate. The card's ⛔ on packages/spec concerns registering sharing_rule as a metadata kind, and none was needed (below). This is declared as a deviation in the report.
  • packages/lint. Its author-facing text named the seeder and said "never seeded".
  • packages/runtime (one test) and packages/qa/dogfood. Pins on the seeder's rows.

Measurements (mechanism assumptions A1–A7)

Evidence

  • @objectstack/plugin-sharing: test 48 files / 1131 passed, and typecheck 0 (including the test typecheck).
  • @objectstack/lint: 144 / 6858 passed, and typecheck 0.
  • @objectstack/cli:
    • typecheck 0.
    • --project unit: 310 files. Two needed the CLI's own dist built first and passed after pnpm --filter @objectstack/cli build.
    • Every organization-ownership* suite, both tiers: 17 passed, 1 skipped (sqld).
  • @objectstack/runtime: the declaration-copy pin 13/13, and typecheck 0.
  • @objectstack/dogfood: typecheck 0. The rbac-seeding, d3-d4, org-scoped-listing, authz-conformance, criteria-required and org-less-caller suites passed.
  • These suites were re-run after merging origin/main (e3658ab).
  • Ablation (scripts/ablation-replace.mjs, restored to HEAD, git diff HEAD empty both times):
    • declaredRules returning [] in listRules turned 7 of the new registry-read pins red.
    • Skipping the ceremony's rename turned 6 of the stage-9 pins red.
  • Gates. dispatch-gates --commands derived 82 families. --ran reports 82 run, 0 NOT-MEASURED, on e3658ab.
    • check:doc-authoring first went red on #23952 in the departure label. See the first acceptance note.
    • check:slot-lookup first went red on three new untyped service lookups. They are typed now.
    • check:dual-build-cjs-loads passed once the eight packages it reads were built.
    • check:plugin-teardown-shape --self-test passed after fetching its pinned commit into the shallow clone.

Acceptance notes

  • Narrowings (FROM → TO), BREAKING, shipped as minor in the launch window. DELETE /api/v1/sharing/rules/:idOrName on a declared rule: 204 → 403 PERMISSION_DENIED (remedy: change or remove the declaration in its package). sys_sharing_rule: one managed_by: package row per declared rule → none (remedy: GET /api/v1/sharing/rules lists declared rules; run os migrate organization-ownership --apply with a fresh plan). ADR-0087: registered sharing-rule-declared-mirror-retired. This line and Clause-②: no (narrowing) above were written by the epic seat from the dev's patch-round-1 report (6253831516 on C3 stage 9 (#15204): declared sharing rules read from the registry, bootstrapDeclaredSharingRules retires, and the ceremony deletes the declared-rule mirrors; the verified marker follows (director ordering ruling) #23952), which names this edit.
  • Ruling β item 6 vs. check:doc-authoring. Item 6 asks the declared-rule-mirrors post-check label to name this card by number. The gate refuses a tracker id in a runtime string (maintainer ruling 2026-08-12, and its baseline is maintainer-only). The label names the step and the stage instead: "deleted by the mirror-deletion step (ADR-0131 C3 stage 9)". The card number sits in the adjacent comment. After this change the rows no longer wait for a later card: the ceremony itself removes them.
  • Walled databases. The step also deletes organization-stamped uncustomized copies, the walled seeder's per-organization rows, which are not NULL rows of the departure. The selector is the departure's, and item 4 names the same population without restricting it to NULL rows. Left standing, those copies would answer for their names with content frozen at the last seed.
  • Undeclared mirrors. A mirror whose rule no installed package declares is deleted when no grant names it, like every fate-2 mirror. When grants still name it, the ceremony blocks and names its remedy: deleting it would withdraw that access on the operator's behalf.
  • Setup's Sharing Rules list. It is the generic sys_sharing_rule object view, so it no longer shows declared rules. GET /api/v1/sharing/rules lists both kinds. The consumer follow-up is Setup → Sharing Rules lists only stored rules once declared rules leave sys_sharing_rule (objectstack C3 stage 9) objectui#12982, dispatched after this PR merges.
  • DefineSharingRuleInput.managedBy. The spec contract keeps the package / platform input. The seeder was its only producer, so it now has none. defineRule keeps honouring it, so nothing narrows.
  • Same-name rows created at runtime. Such a row answers for a declared name from its creation. The declaration's name-keyed grants in that scope are swept at the next boot, not at once. What switching off a vendor's declared rule should mean is decision [Decision] Declared sharing rules: what switching off a vendor's rule means once they are read from the registry #24378.
  • #14491 is not addressed here: its parse axis is unchanged (A6).

Generated by Claude Code

…egistry; the ownership ceremony deletes the seeded copies

ADR-0131 D2 / C3 stage 9 (ruling beta on the C3 epic: stage 9 first,
the verified marker after it).

plugin-sharing: bootstrapDeclaredSharingRules retires. The sharing-rule
service reads a stack's declared sharingRules from the registry
(engine.registry, else the metadata service) on every read of the rule
set, translated as the seeder translated them, and projects each one per
scope: once organization-less under `single`, once per organization
under a wall. A declared rule's id is its name, which is what its
sys_record_share grants carry as source_id; one organization's grants
are told apart from another's by their organization. A stored row of
the same name answers for it. A declared rule cannot be deleted through
the service (403). sys_sharing_rule holds only organization-authored
rules.

cli: DECLARED_SHARING_RULE_SEEDER_RUNS flips to false. The ceremony's
mirror-deletion step executes the declared-rule-mirrors departure:
every grant naming a mirror row by id is renamed to the rule's name,
verified, and only then are the mirrors deleted; it re-runs on a
checkpointed ceremony while a mirror stands, and blocks on a mirror
whose rule no package declares while grants still name it.

lint: the sharing-rule enforceability text says "never served" rather
than "never seeded". The liveness ledger's producer pointers follow the
renamed module.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHtySYfURHqg298wCCJ8wj
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/lint, @objectstack/plugin-sharing, @objectstack/spec, touching 103 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/sharing_rule.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5da2e88818a006145330e3f323759ebef4d6ca87.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/sharing_rule.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 34 pages)
  • 21 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 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 5da2e88818a006145330e3f323759ebef4d6ca87 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6351373e631ef16f8f2e1b7c7670d9b972f89524 — the merge of head e0baaa76c69c4d53e43cad8002f7cda487ccd0b3 into base 5da2e88818a006145330e3f323759ebef4d6ca87, 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 6351373e631ef16f8f2e1b7c7670d9b972f89524 && git checkout 6351373e631ef16f8f2e1b7c7670d9b972f89524
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5da2e88818a006145330e3f323759ebef4d6ca87 e0baaa76c69c4d53e43cad8002f7cda487ccd0b3 && git checkout -B drift-repro 5da2e88818a006145330e3f323759ebef4d6ca87 && git merge --no-ff e0baaa76c69c4d53e43cad8002f7cda487ccd0b3

node scripts/docs-audit/affected-docs.mjs --json 5da2e88818a006145330e3f323759ebef4d6ca87

⚠️ 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 5da2e88818a006145330e3f323759ebef4d6ca87 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e3658abc263d3e29a4cf3f36fe10327fa181f80f
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/cli/src/utils/organization-ownership-apply.ts, packages/cli/src/utils/organization-ownership-inventory.ts, .changeset/23952-declared-sharing-rules-registry.md + packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/sharing-rule.test.ts, packages/plugins/plugin-sharing/src/field-recipient.test.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts, packages/cli/src/utils/organization-ownership-apply.test.ts, packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts, packages/lint/src/{authoring-rules,rule-explanations,validate-sharing-rule-enforceability}.ts and their two tests, packages/qa/dogfood/test/{authz-conformance.matrix,expression-conformance.ledger,org-scoped-sharing-rule-listing.dogfood.test,showcase-d3-d4-capabilities.dogfood.test,showcase-declarative-rbac-seeding.dogfood.test}.ts, packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts, packages/spec/liveness/sharing_rule.json, packages/spec/scripts/liveness/proof-registry.mts (the whole 27-file diff: on the narrowing face no path is a remainder); cited code read: packages/rest/src/sharing-routes.ts (the DELETE door and its handleError), packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts (managed_by readonly), packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts (indexes), packages/plugins/plugin-sharing/src/sharing-service.ts (the grant upsert key), packages/platform-objects/src/system/migration-flag.ts (recordDataMigrationRun), packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts (the semantic-entry precedent), the deleted packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts at the merge base, docs/adr/0131 (D2, D6, D10, D13, D14), docs/adr/0112 (D1–D5 and the 2026-08-17 flat-door amendment), docs/adr/0087 (the disposition vocabulary), docs/adr/0057 D6, scripts/check-adr-0087-registration.mjs, scripts/check-doc-authoring.mjs, scripts/pm/record-recognisers.mjs and scripts/pm/clause2-line.mjs, .claude/skills/pm-dispatch/references/{contract-review,execution-duties,filing-gate}.md.

Rendered by an isolated at-tier reviewer subagent of the adopting seat (epic:#15194), read-only, from the card (#23952 body and all four comments), the rulings it cites (β 6216760903 and the stage-0 addendum 6100442578 on #15204, the hand-off 6220693269, decision #24352 ruled C in 6251599171), the PR body, its file list, the net diff origin/main...e3658abc2 fetched into a reviewer-owned ref, and the head's check-runs. Not read: the dispatch order or the dispatching seat's conclusions.

Check-runs on the head, as read (the 2026-10-18 17:14Z run, last re-read 17:43Z): 40 check-runs — 32 success, among them the required contexts Lint & Repo Gates, Build Core, Governed Surface Queue Guard, every Type Check · lane including Type Check · workspace, Check Changeset, Spec property liveness, Dogfood Regression Gate, Check PR Size, the three card/branch claim guards, and Test Core 1/6, 2/6, 5/6, 6/6; 6 skipped by design (Build Docs, Console Pin Gate, the dogfood matrix shard rows, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke); Test Core 3/6 and 4/6 still in_progress at the last read; 0 failure. The adopting seat re-reads the two open Test Core shards before any landing act; this record's verdict does not depend on them (it fails on the items below).

① Derived judgments

Every accept-set and public-surface change the diff implies, named right or wrong.

(a) DELETE /api/v1/sharing/rules/:idOrName on a declared rule: 204 → 403 PERMISSION_DENIED. RIGHT as an answer: a declared rule has no row (sharing-rule-service.ts:967-981: findRuleRow first, then the declared projection, then the throw); a silent 204 would read as success while the rule keeps granting, and a 404 would contradict the same caller's GET/list one call earlier — the #7795 reasoning, carried over exactly. Refusing a system context alike is right: there is nothing to delete and code is changed in code. The throw is the service family's prefix convention (Error('PERMISSION_DENIED: …')), which the door maps at packages/rest/src/sharing-routes.ts:578-580 to res.status(403).json({ code: 'PERMISSION_DENIED', error }) — the family's flat dialect, a live envelope under ADR-0112 (the 2026-08-17 #9232 amendment: code is the closed StandardErrorCode member the status derives; the body POSITION is owned by the check:route-envelope ratchet, not by this PR). So at the REST door the answer is 403 with code: PERMISSION_DENIED — the right code and the right status, and no new door shape. Also right: getRule/listRules/evaluateRule by NAME keep working for the same caller (pinned at sharing-rule.test.ts:2218-2220 and in the dogfood listing test), so the 403 is not a lie. WRONG — the pins: sharing-rule.test.ts:2221 and :2223 pin the refusal with rejects.toThrow(/^PERMISSION_DENIED: …/) and /^PERMISSION_DENIED/, a message regex. This file's own #7795 block (:1320-1360) states the rule that governs here — "a loose .toThrow() — or even /PERMISSION_DENIED/ as a substring — cannot tell them apart" — and pins with refusalCodeOf (the exact ADR-0112 token), expect(HttpStatusErrorCodeMap[403]).toBe(code) (the status SOURCED, not restated), not.toBe(HttpStatusErrorCodeMap[404]) / 'RULE_NOT_FOUND', and "the refusal leaves … every tenant's grants intact". None of the four is asserted for the declared-rule refusal: no pin sources the status, none asserts the token exactly, none asserts the declaration's grants are left standing (the throw precedes purgeGrants, so they are — unpinned). Under this repo's review rules a message regex alone is not a pin. → blocking item 1.

(b) Grants of a declared rule carry source_id = the rule's NAME; the ceremony renames before it deletes. RIGHT. projectDeclaredRule gives the projection id: rule.name (sharing-rule-service.ts:150-166) and every grant read and write goes through ruleGrantScope(rule) (reconcile, reconcileForRecord, purgeGrants, the retired-recipient revoke), so the service has one code path for id-keyed and name-keyed grants (β item 5 (c)). An organization-authored rule keeps its minted srule_… id as source_id (pinned sharing-rule.test.ts "an organization-authored rule keeps working through its row"). The ceremony's runDeclaredRuleMirrorDeletion (organization-ownership-apply.ts:2561-2651) works in the ruled order and the order is provable from the code, not only the prose: (1) mirrors read by the departure's own selector (:2503-2518, managed_by package/platform and customized not true, NULL and organization-stamped rows alike), (2) the block on a mirror whose rule no installed package declares while grants still name it, nothing written (:2586-2596), (3) the rename of every sys_record_share row with source = 'rule' and source_id = the mirror's id to the mirror's NAME (:2598-2613), (4) the verification that zero grants still name any mirror id, which THROWS before any delete (:2614-2622), (5) the delete by id with the selector re-asserted and a count to zero (:2626-2643). Pinned: the write-log order lastRename before firstDelete (organization-ownership-apply.declared-rule-mirrors.test.ts:253); every grant resolves by name or by a kept row's id, the customized copy and the organization's rule untouched (unit :181-255, integration :255-262, organization-ownership-apply.test.ts:892-924); idempotent — a second run finds no mirror and writes nothing to either table (:257); the checkpointed re-run on a database certified by a seeding release (:266, the hotcrm dev.db case); the undeclared-with-grants block writes nothing (:288); the grant-less undeclared mirror goes like every fate-2 mirror; the no-registry block; the walled organization-stamped copy goes and the customized one stays (:321). A blocked re-run cannot leave a stale certification: every ledger write goes through recordDataMigrationRun, which sets verified_at only when passed (migration-flag.ts:198), so the first checkpoint write of a re-run un-verifies the marker and a blocked run leaves it un-verified — since #24325 that database is not served, which is the ruled posture. The rename cannot collide: sys_record_share carries no unique index on (source, source_id) (sys-record-share.object.ts:268-277) and SharingService.grant upserts on (object, record, recipient, source) (sharing-service.ts:1417), and while a mirror stands it answers for its name (the held set), so no name-keyed grant exists before the deletion; afterwards the first reconcile adopts the renamed rows by (record_id, recipient_id). Partial failure between (3) and (5) is safe: the next run counts zero grants on those mirrors and deletes them. One gap, NOT blocking, recorded for the dev: DeclaredSharingRulesPort.names() (organization-ownership.ts:802-818) answers the REGISTRY's item names, not the rules enforcement SERVES — a declared rule whose translation is skipped (unlowerable condition, unmappable recipient) resolves as declared, its mirror's grants are renamed to a name no projection serves, and the next boot's sweep revokes them. The 17.x seeder never seeded such a rule, so the case needs a condition the 17.x compiler lowered and the v18 compiler refuses; narrow, but the ceremony's own principle ("deleting it would withdraw that access on the operator's behalf") says the port should answer served names, or the step should warn when it renames onto an unserved one.

(c) The walled posture. No cross-tenant read or write of GRANTS: ruleGrantScope stamps organization_id on every read and write of a walled projection's grants, criteriaContext(rule) (:1432-1436) threads the projection's organization into the record query, declaredScopesFor (:882-887) gives an organization-scoped caller only its own projection and a system or platform caller every known one (by authority), evaluateRule on a declared name under a wall runs one pass per projection and reports one rule; pinned ("one organization's reconcile leaves the other organization's grants of the same rule standing", "the sweep keeps each organization's served grants"). The holders read (:862-871) is a SYSTEM_CTX read of name and organization_id across organizations, used only to compute shadowing — no row content crosses a tenant, and nothing a tenant did not already see through adminOrgScope. An organization created after boot is served from its creation (addDeclaredRuleScope, the creation middleware; pinned). WRONG — the scope set is allowed to be INCOMPLETE and the service cannot tell "unknown" from "none": resolveDeclaredRuleScopes (sharing-plugin.ts:88-110) returns [] when sys_organization cannot be read (:105) and otherwise enumerates at most DECLARED_RULE_ORGANIZATION_SCAN_LIMIT = 500 organizations (:77, :98) with no orderBy. At kernel:bootstrapped the plugin then runs sweepOrphanedRuleGrants() (sharing-plugin.ts:1108), whose liveDeclared set is built from declaredRules(SYSTEM_CTX, {}) (sharing-rule-service.ts:1095-1098): for every organization NOT in declaredScopes, every grant whose source_id is a declared rule's name is revoked (:1113). Two consequences, both new: (i) a transient sys_organization read failure at boot revokes every declared rule's grants in every organization of a walled deployment — the very rows the ceremony just renamed under item 5 (c) — until a later boot's backfill re-materialises them; (ii) the 501st and later organizations are never served (no enforcement there) and have their grants revoked at every boot. Under the seeder the same failure left the already-seeded rows standing and their id-keyed grants live, and the bound only delayed seeding; the plugin's own comment ("an organization past the bound is covered by the organization-creation hook and by the next boot", :74-76) is false in the read model, and the changeset's "no declared rule is served until the next boot" does not say the boot sweep withdraws their grants. A read failure must not be a revocation. → blocking item 2.

(d) A stored row of the declared name answers for it. INHERITED, not new: the seeder's defineRule upsert looked the row up by { name } under SYSTEM_CTX (any organization's row) under single, and by { name, organization_id } per organization under a wall, and never overwrote a managed_by: admin or customized row (sharing-rule-service.ts:672-718, the #2909 seed-not-clobber), so a same-name stored row has answered for a declared name since #2909; the held key (:869: name alone under single, name::organization under a wall) reproduces that key exactly. SAFE against forgery: isDeclaredSharingRule needs managed_by === 'package' AND id === name; managed_by is readonly (the engine strips it from non-system payloads, sys-sharing-rule.object.ts:248-253) and row ids are minted. Two consequences ARE new, both acknowledged in the PR body, neither blocking, both owed to the item (b) decision card (③ item 1): an administrator can now CREATE a same-name row where no seeded row exists (before, the row existed and the edit stamped customized), which switches the declaration off in that scope — silent shadowing by NAME, the trap the dev names; and the shadowed declaration's name-keyed grants stand until the next boot's sweep, because bindRuleRebindTriggers reconciles only the written row's id (sharing-plugin.ts:456-466) — a one-restart over-sharing window the seeder era did not have (deactivating the seeded row purged its grants at once through evaluateRule).

(e) DECLARED_SHARING_RULE_SEEDER_RUNS = false and decision #24352 → C. RIGHT and consistent. The constant flips in this change and no other (organization-ownership-inventory.ts:676; the hand-off 6220693269's one order), advisoryDepartureReason therefore answers null for the mirrors and they hold certification until the step deletes them (β items 1–2: the marker keeps meaning zero NULL rows); the integration test asserts the constant against what a served boot actually writes (:222) and that eleven mirrors with grants are renamed and deleted to [complete, blocking, advisory] = [true, 0, unattributable] (:243). Decision #24352 → C (6251599171) makes UNATTRIBUTABLE rows block in stage 8c-0, which the ruling orders after this PR's ceremony half and before 8c-1; this PR keeps them advisory (advisory = after.summary.unattributableRows, asserted as unattributable in the integration test) — consistent with the ruled sequencing, and the 8c-0 PR will flip that one pin. β item 2 is thus met in the two ruled steps.

Other surfaces the diff moves, all RIGHT: GET /api/v1/sharing/rules lists a declared projection with id = name, managed_by: 'package', customized: false, organization_id null under single / the organization under a wall (the seeded row listed with the same fields and a minted id before); POST …/:name/evaluate works by name (pinned in the dogfood listing test); the boot writes zero sys_sharing_rule rows in every posture and a declared rule still enforces, with the positive control that removing the declaration stops the grant (sharing-rule.test.ts "POSITIVE CONTROL"; the card's first pin); the per-record hooks, the business-unit recompute, the backfill and the sweep all list through listRules (A1 of the PR body holds on read); @objectstack/lint's verdicts change prose only, rule ids and severities unchanged (patch); the sharing_rule liveness producer anchors are re-pointed to declared-sharing-rules.ts#translateDeclaredSharingRule (evidence pointers, Spec property liveness green); DefineSharingRuleInput.managedBy keeps its meaning with no in-repo producer (the acceptance note is right). WRONG — a claim in the cited code: declared-sharing-rules.ts:7-9 says the seeder's "retirement note is the ADR-0087 sharing-rule-declared-mirror-retired entry"; no such entry exists anywhere in the tree at this head (the only occurrence is that citation), the changeset's marker says "No ADR-0087 id covers this and this diff adds none", and card item 2 asked for the retirement note to be written as the ADR-0087 entry ("the seeder is machinery, not an authorable shape, so the ADR-0087 entry is the note"). A code comment citing a registry entry that does not exist is a decision nobody can find. → blocking item 3 (with ②).

② Semver level

The changeset bumps @objectstack/plugin-sharing minor, @objectstack/cli minor, @objectstack/lint patch, declares breaking through the ! summary and the **BREAKING** banner, and carries the adr-0087: disposition not-required (no-migration-prescription). The LEVELS match what the diff publishes: both runtime packages break (the DELETE refusal; sys_sharing_rule no longer holding a row per declared rule; the ceremony deleting the copies), shipped as minor under the launch-window convention check-changeset-no-major enforces; lint is prose-only. Check Changeset is success on the head.

The Clause-②: line is WRONG and MUST read Clause-②: no (narrowing), at the start of a line of its own, on BOTH carriers — the PR body and .changeset/23952-declared-sharing-rules-registry.md:9. DELETE /api/v1/sharing/rules/:idOrName on a declared rule narrows a published REST accept set (204, the seeded row deleted → 403 PERMISSION_DENIED): filing-gate.md — "REST/数据门收窄亦算"; execution-duties.md — a card narrowing a published accept set is Clause-②: no and owes a contract-review-tier review before the queue; contract-review.md and record-recognisers.mjs — the narrowing face is keyed on the one spelling Clause-②: no (narrowing) written at the start of a line in the PR body and the changeset. Two things follow from the bare no as committed: reviewFace on this file list defers to the changeset's line and reads the DECLARATION face (TIER_DEFAULT), so this at-tier record does not stand on the committed tree until the line is corrected ("exact, never a floor"); and check-adr-0087-registration reads this PR's breaking-ness only off the ! and banner prose the author happened to type — the carrier that does not depend on prose is the arm. The narrowing is written FROM → TO in the changeset: DELETE /api/v1/sharing/rules/:idOrName for a declared rule — FROM 204 with the seeded row deleted and re-seeded at the next boot, TO 403 PERMISSION_DENIED; sys_sharing_rule — FROM one managed_by: package row per declared rule, TO none (the ceremony deletes the copies; Setup's object list no longer shows them).

The ADR-0087 disposition marker is held by a detector miss and must be re-decided with the arm: the body already names the operator's re-run (:25, "Re-run --apply with a fresh plan") and the FROM → TO above, written in the house label convention, is exactly what the gate's branch 1 reads as a prescription — ADR-0087: "no-migration-prescription is refused by a body that carries a migration prescription". The honest disposition is registered sharing-rule-declared-mirror-retired: a SemanticMigration at packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts, in the shape of 18.standard-error-code-batch-members-retired.ts (a wire-level narrowing with no stored-metadata rewrite — "this entry is the notification channel"), its surface the DELETE refusal, the deleted copies and the Setup list, its replacement "change or remove the rule in its declaration; an organization's own rule is deleted as before", its reason ADR-0131 D2 / C3 stage 9 and ruling β. That one file makes the docblock citation true, discharges card item 2's retirement note and D13's "each retirement … is an ADR-0087 entry", and the gate re-verifies registered (the id resolves and is new in the diff — it would be). It is a packages/spec/src touch outside the claim's file surface: the claim's ⛔ targeted registering sharing_rule as a metadata kind, which the dev's A2 showed unnecessary; the adopting seat widens the claim for this one file or routes it to the spec seat — either way the entry is the card's item 2. If the seat instead rules the entry not owed, the citation at declared-sharing-rules.ts:9 is deleted and no-migration-prescription stays, with the FROM → TO stated outside the label convention; the comment and the changeset must agree, and today they contradict each other.

③ Boundary flags

Every dev flag and every open_questions entry in the os-dev-report (6252786437), answered or escalated.

  1. Item (b) — what switching off a vendor's declared sharing rule means (open_questions[0], the addendum's (b), β item 5 (b)): not settled by governing text — ADR-0131 D6 charts regimes O, C and E and names none for sharing_rule; ADR-0057 D6 speaks only to seeding. The dev built no disable mechanism and recommends A (regime C: the tenant-less activation ledger, name-shadowing retired after the mirrors go). Answer: right to build nothing in this PR (the card: "it does not block items 2–5"). ESCALATED to the adopting seat, which files the decision card the card body owes ("the epic PM files a decision card"); the card must carry the two consequences measured in ①(d) — a freshly created same-name row now switches a vendor rule off silently, and the shadowed declaration's grants stand until the next restart — as evidence, not only the inherited shadow.
  2. β item 6 label deviation (open_questions[1]): the declared-rule-mirrors post-check label names the step and the stage ("deleted by the mirror-deletion step (ADR-0131 C3 stage 9)", organization-ownership-inventory.ts:545-547), not the card number, because check:doc-authoring's ledgered cross-package leg refuses a tracker id in a packages/** runtime string (shrink-only baseline; the gate's own text: comments are the sanctioned home for internal anchors) — the number sits in the adjacent comment. Option A is right: the ruling's stated purpose ("so an operator reads what the rows wait for") is met, and after this change the rows wait for nothing beyond this release's own step. ESCALATED as a record, not a question: β is a director ruling, so the deviation is written on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204's thread by the adopting seat, one line, no gate change (B would widen a maintainer-only ratchet for a number no operator can resolve).
  3. packages/spec/liveness/sharing_rule.json and packages/spec/scripts/liveness/proof-registry.mts (deviation 1): evidence pointers only — nine path#Symbol producer anchors re-pointed from the deleted seeder to declared-sharing-rules.ts#translateDeclaredSharingRule, plus prose; no schema, type or generated artifact; check:liveness would red without them and Spec property liveness is success. ACCEPTED: the claim's ⛔ was tied to registering sharing_rule as a kind, which A2 measured unnecessary (the registry keys items by the METADATA_ARRAY_KEYS type string; the metadata service binds the type through UNREGISTERED_KIND_SCHEMAS). The seat notes the widening in its ACCEPT; ② adds one more packages/spec/src file to decide.
  4. Setup's Sharing Rules list no longer shows declared rules (the out-of-scope note; carrier given as the item (b) card): the generic sys_sharing_rule object view lists rows, and declared rules have none; GET /api/v1/sharing/rules lists both. ESCALATED with a different carrier: this is a user-visible regression of Setup — an administrator can no longer see, read or evaluate a vendor rule from the page that lists sharing rules — a defect to file under Prime Directive chore: version packages #10, not a policy question for a decision card. The adopting seat files the consumer card (objectui: the Sharing Rules page reads /api/v1/sharing/rules and offers evaluate; delete disabled for a declared rule, the 403 explained) and names it in the ACCEPT. It does not block this PR: enforcement is unchanged and the REST surface lists both kinds.
  5. Walled organization-stamped copies deleted too (open_questions[2]): A is right — the departure's selector names them, card item 4 names the population with no NULL qualifier, D2 retires the per-organization copying, and a standing copy would answer for its name with content frozen at the last seed; a customized copy stays and keeps answering (pinned :321). Answered.
  6. DefineSharingRuleInput.managedBy has no producer — zero-pull, the acceptance note is the right carrier. [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy of permissions / capabilities / sharingRules in the ObjectQL SchemaRegistry (AppPlugin.init → manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491's parse axis unchanged — right; the runtime test now reads the served rules and pins sys_sharing_rule empty. Doc-comment prose still saying "the seeder" in validate-sharing-rule-enforceability.ts — prose drift, not filed; fine. Worktree, squash and trailer deviations — process, outside this record's face.
  7. Not flagged by the dev, flagged here (①): the names() port answering registry names rather than served names (a note for the dev, not blocking), and the walled scope-set defect (blocking item 2).

Blocking items — each with the fix it needs:

  1. packages/plugins/plugin-sharing/src/sharing-rule.test.ts:2218-2224 — pin the declared-rule DELETE refusal the way this file's Decision: may an org-level sharing admin DELETE a platform-global (organization_id = null) sharing rule? #7795 block rules: refusalCodeOf(...) equals 'PERMISSION_DENIED' exactly; HttpStatusErrorCodeMap[403] equals that code (the status sourced, not restated) and the code is not HttpStatusErrorCodeMap[404] nor 'RULE_NOT_FOUND'; the refusal leaves the declaration's grants standing (grant count unchanged after the refused call); for both the org-scoped admin and the system context. A pin at the REST door (DELETE on a declared name answers 403 with top-level code: 'PERMISSION_DENIED') is welcome; the service pin that sources the status is the floor.
  2. packages/plugins/plugin-sharing/src/sharing-plugin.ts:74-110 (resolveDeclaredRuleScopes, DECLARED_RULE_ORGANIZATION_SCAN_LIMIT) and packages/plugins/plugin-sharing/src/sharing-rule-service.ts:341-366, 1082-1118 (setDeclaredRuleScopes, sweepOrphanedRuleGrants) — make "unknown" representable and never a revocation: enumerate every organization (page through sys_organization, no bound, or a bound that fails loudly rather than truncating); make a failed enumeration a distinct state under which declaredRules serves nothing AND the sweep leaves name-keyed grants standing with one WARN naming the remedy (the ceremony blocks rather than withdraws; the runtime does the same); rewrite the :74-76 comment. Pins: a failed enumeration at boot revokes zero grants and warns; a deployment with more organizations than any bound serves every one.
  3. .changeset/23952-declared-sharing-rules-registry.md:9 and the PR body — Clause-②: no (narrowing), the narrowing written FROM → TO as in ②; and packages/plugins/plugin-sharing/src/declared-sharing-rules.ts:7-9 — the cited ADR-0087 entry sharing-rule-declared-mirror-retired must exist: add packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts and change the marker at .changeset/…:11 to registered sharing-rule-declared-mirror-retired (the adopting seat widens the claim for that one spec file, or routes it to the spec seat); or, if the seat rules the entry not owed, delete the citation and keep no-migration-prescription with the FROM → TO stated outside the label convention. The comment, the changeset and card item 2 must say one thing.

Implemented-by: claude/issue-23952-declared-sharing-rules-registry
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e0baaa76c69c4d53e43cad8002f7cda487ccd0b3
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts (deleted), packages/plugins/plugin-sharing/src/sharing-rule.test.ts, packages/plugins/plugin-sharing/src/field-recipient.test.ts, packages/cli/src/utils/organization-ownership-apply.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts, packages/cli/src/utils/organization-ownership-apply.test.ts, packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/validate-sharing-rule-enforceability.ts, packages/lint/src/validate-sharing-rule-enforceability.test.ts, packages/lint/src/validate-sharing-rule-enforceability.cross-class-field.test.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/qa/dogfood/test/expression-conformance.ledger.ts, packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts, packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts, packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts, packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts, packages/spec/liveness/sharing_rule.json, packages/spec/scripts/liveness/proof-registry.mts, .changeset/23952-declared-sharing-rules-registry.md (the whole 28-file diff: the recogniser's one hits path first — the spec entry puts this tree on the schema face, the same tier name — then every other path; on the narrowing face no path is a remainder; the changeset is widened in as the Clause-②: carrier) + cited code read: packages/rest/src/sharing-routes.ts (handleError :570-583, the DELETE door :731-747), packages/rest/src/sharing-rule-evaluate-inert-object.test.ts (:158-170, the door's 403 arm pin), packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts (:316-320, the DELETE route's 403), packages/platform-objects/src/identity/sys-organization.object.ts (:442, id: Field.text), packages/platform-objects/src/system/migration-flag.ts (recordDataMigrationRun :189-198), packages/core/src/hook-dispatch.ts (:154-155) and packages/core/src/kernel.ts (:550-620, the sequential boot phases), packages/spec/src/api/errors.zod.ts (HttpStatusErrorCodeMap :183-197), packages/spec/src/migrations/types.ts (SemanticMigration :99-134), packages/spec/src/migrations/registry.ts.template (STEP18_RATIONALE :1188-1207), packages/spec/src/migrations/entries/README.md, packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts (the precedent), packages/spec/src/migrations/migrations.test.ts (:135-193), packages/spec/scripts/build-migration-registry.ts (shardNameFor :182-184, parseEntry :193-258), packages/spec/src/kernel/protocol-version.ts (:22), packages/plugins/plugin-sharing/src/index.ts (:103-109), the deleted seeder at the merge base a2a3777 (its refusal set :220-291), scripts/check-adr-0087-registration.mjs (:505-511, :621-686, :1997-2001, :4863-4886), scripts/check-changeset-no-major.mjs (the launch-window header), scripts/check-commit-card-trailers.mjs (header), scripts/pm/record-recognisers.mjs and scripts/pm/clause2-line.mjs, docs/adr/0131 (D2 :274-284, D13 :679-701, D14 :703-719), AGENTS.md (Degradation log levels; the attribution footer and commit-trailer rule; Post-Task Checklist 3), .github/workflows/pr-automation.yml (:976-977, :1127) and lint.yml (which job carries which gate).

Rendered by an isolated at-tier reviewer subagent of the adopting seat (epic:#15194), read-only, from the card (#23952 body and all six comments, the claim amendment 6253100453 and the patch-round-1 addendum 6253831516 included), the rulings it cites (β 6216760903 and the stage-0 addendum 6100442578 on #15204, the hand-off 6220693269, decision #24352 ruled C in 6251599171), the PR body as the seat edited it, its file list, every comment on it (the FAIL record 6253094552 this round answers; zero review comments, zero reviews), the net diff origin/main...e0baaa76c fetched into a reviewer-owned ref, the round's delta e3658abc2..e0baaa76c, and the head's check-runs. The record's shape and face were read with record-recognisers.mjs --template --paths as the brief instructs; nothing was built, run or re-run. Not read: the dispatch order or the dispatching seat's conclusions.

Check-runs on the head, as read (2026-10-18, the 18:30Z run and the 19:05Z body-edit re-run): 48 check-runs, 0 failure, 0 in_progress. Every gate-carrying job is success: Lint & Repo Gates and all eight Lint · lanes, TypeScript Type Check and all four Type Check · lanes, Build Core, Test Core with all six shards, Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Check PR Size, Check Changeset (both runs — the newest, 19:05-19:07Z, read the seat's edited body and the committed changeset), the three card/branch claim guards (both runs), Check Documentation Links, Flag docs affected by code changes. 10 skipped by design (Build Docs, Console Pin Gate, the dogfood matrix shard row, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke, and Auto Label / Check PR Size on the body-edit re-run). Check Changeset is the job that runs check-adr-0087-registration.mjs and check-changeset-no-major.mjs (pr-automation.yml :976-977, :1127); Spec property liveness is the gate the two packages/spec pointer files exist for.

① Derived judgments

Every accept-set and public-surface change the diff implies, named right or wrong — the whole PR, not only the round's delta. The three items the FAIL record listed as blocking first, each verified against the code at this head rather than against the addendum.

(1) The declared-rule DELETE refusal is pinned on the code and on a sourced status, for both callers. RIGHT, fixed. packages/plugins/plugin-sharing/src/sharing-rule.test.ts:2224-2253 — refusalCodeOf takes the thrown message's prefix up to the first colon (the exact ADR-0112 token, never a substring), and the it.each at :2233 runs the same body for an org-scoped manage_sharing admin and for the system context: code is exactly PERMISSION_DENIED (:2244); HttpStatusErrorCodeMap[403] equals that code (:2246, the status SOURCED from packages/spec/src/api/errors.zod.ts:186, not restated); the code is neither HttpStatusErrorCodeMap[404] nor RULE_NOT_FOUND (:2248-2249); the same caller getRules the declaration first (:2240); the declaration's grants are byte-identical after the refused call and it is still served (:2251-2252). In the code, deleteRule (sharing-rule-service.ts:993-1008) looks the row up first, then the projection, then throws PERMISSION_DENIED: … at :1001-1006 — before purgeGrants at :1031 — so the pinned invariant is the code's order, not a coincidence of the fake. Is the service-level pin enough, given that handleError makes the mapping? Yes. The door maps by message PREFIX (sharing-routes.ts:578-580: msg.startsWith('PERMISSION_DENIED') → 403 with top-level code: 'PERMISSION_DENIED'), and the service pin asserts exactly that prefix as the token, so the two halves meet on one string. The door half is already pinned where it lives: packages/rest/src/sharing-rule-evaluate-inert-object.test.ts:158-170 drives the shared handleError with PERMISSION_DENIED: … and asserts 403 plus codeOf(body) === 'PERMISSION_DENIED'; the DELETE route itself (sharing-routes.ts:742-744) catches into that same handler, and packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts:316-320 pins a DELETE on that route answering 403 end to end for the sibling #7795/#8158 refusal. No new door shape is introduced, so a second door pin for this one message would pin the handler twice and widen the diff into packages/rest for nothing. The floor the FAIL record set is met. One consistent edge, noted, not wrong: under UNKNOWN scopes (item 2) a declared name is served nowhere, so getRule answers null (the door's 404 RULE_NOT_FOUND, :724) and deleteRule returns silently (204) — the contract an unknown name has always had, not a new no-op on a rule that keeps granting.

(2) The walled scope set is complete or UNKNOWN, never partial, and a failed read is never a revocation. RIGHT, fixed. Verified in the code, path by path:

  • The keyset walk. sharing-plugin.ts:93-137: under a wall, engine.find('sys_organization') with orderBy id asc, limit 500 (DECLARED_RULE_ORGANIZATION_PAGE, :78 — a page, no cap) and id $gt last from the second page on, until a short page; the id is Field.text (sys-organization.object.ts:442), so seek pagination on it is well-defined on every driver. The same $gt-cursor shape two plugin-audit backfills already use. Pinned: 1,201 organizations over 3 pages with the order asked on every page (sharing-rule.test.ts:2374-2382), a 1,000-row set that lands exactly on a page boundary still ends (:2384-2388, 3 calls), single reads nothing (:2397-2401), and an organization past the former 500 cap is served and keeps its grant through the boot sweep (:2403-2437).
  • Any page failing → null. :103-109 catches at any page, warns once with the consequence and the remedy, and returns null — never the ids read so far (read: ids.length is logged, not returned). Pinned at :2390-2395: a failure on page 2 answers null and the warn names "no declared rule's grant is revoked".
  • setDeclaredRuleScopesUnknown(). The plugin calls it on null (:799-801). In the service, declaredScopes is null as a state of its own (sharing-rule-service.ts:346, :375-377); declaredScopesFor answers [] (:908 — served nowhere, so listRules, getRule, evaluateRule, the hooks and the backfill all see no declared rule); addDeclaredRuleScope keeps it unknown (:384-388 — one organization known is not the set, pinned at :2322); and sweepOrphanedRuleGrants (:1108-1167) holds EVERY declared name live under unknown scopes (:1130-1142, read from the source regardless of scope), says so once per process (:1133-1141), and still revokes a true orphan — a grant whose rule ROW is gone (:1151-1159, pinned at :2307-2329: the orphan goes, both organizations' declared grants stay, one warn across two sweeps). The CONTROL at :2331-2335 proves the sweep is live under KNOWN scopes that leave an organization out, so the unknown-scopes hold-back is a distinct branch and not a dead sweep.
  • Is any path left where a failed or partial read deletes a live grant? None found. (i) The [null] default of declaredScopes before setDeclaredRuleScopes runs would serve a walled deployment organization-less for a moment — unreachable: the whole block is one kernel:ready handler (sharing-plugin.ts:647), the kernel awaits hook handlers one at a time (hook-dispatch.ts:154-155) and plugin starts in order (kernel.ts:550-620), the service is registered at :775 and its scopes set at :799-801 before the first listRules at :826, and REST listens only at kernel:listening. (ii) The short-page test at :114 is taken on the FILTERED page (typeof id === 'string' && id !== ''), so a row whose id is not a non-empty string would shorten the page and end the walk early with a partial list; the id column's declared type makes that row impossible under the schema, so this is a hardening note, not a path: compare the RAW page length, and treat a filtered-out id as a failed read (→ null). (iii) A NULL-organization grant under a wall whose name is served only per organization is swept — such a grant confers nothing under a wall's strict tenant equality (the The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 measurement), so no effective access is withdrawn. Is a rule served to an organization it should not be? No: declaredScopesFor gives an org-scoped caller its own projection only when its organization is in the set (:910-912), a system or platform caller every known one, and ruleGrantScope (:190-194) plus criteriaContext stamp every read and write of a walled projection with its organization — pinned at :2278-2288 (one organization's reconcile leaves the other's grants standing) and :2290-2298 (a row of the declared name answers in ITS organization only).
  • warn, not error. RIGHT under the AGENTS.md rule. The question is whether the system still looks normal from the outside while something it claims persisted has not landed. It does not look normal: no declared rule grants or lists anywhere until the next boot — a functional degradation, visibly smaller — and nothing claimed-persisted is lost, because the standing grants are held, not revoked. The first line at both sites (sharing-plugin.ts:104-107, sharing-rule-service.ts:1136-1138) names the consequence and the remedy, which is more than warn owes. The read-invention half of the same rule is also satisfied in spirit: the catch returns a distinct null, not an invented empty, and logs. error here would train readers to skim error, which the rule names as the over-application to avoid.
  • The :74-76 comment is rewritten (:71-91 now states the page-not-cap rule and why), and the changeset's boot-scope sentence (.changeset/…:25) now says a failed read revokes nothing.

(3) The changeset, the ADR-0087 marker and the semantic entry. RIGHT, fixed; one wording correction owed, not blocking. .changeset/23952-declared-sharing-rules-registry.md:9 reads Clause-②: no (narrowing) at the start of its line; :13-16 write both narrowings FROM → TO with a one-line remedy each; :11 carries exactly one marker, registered sharing-rule-declared-mirror-retired. The entry packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts is in the generator's shape: filename equals shardNameFor(18, id) (build-migration-registry.ts:182-184, :246-255 refuse a mismatch), the major prefix is the current protocol major (PROTOCOL_VERSION = '18.0.0'), export const entry: SemanticMigration = { with id: '…', on its own line and }; last (:209-241), all five required fields present (types.ts:99-112), no conversionIds (right — no D2 conversion exists for machinery) and no relevance question (right — the 26-entry census in semantic-relevance.test.ts counts questioned entries only; migrations.test.ts:135-142 needs non-empty reason and acceptanceCriteria, met). Registration: check-adr-0087-registration.mjs reads the ids of a rev from entries/semantic (:509) and judges registered by two facts — every id resolves at HEAD and at least one is NEW in the diff (:4863-4886); both hold, and the breaking signals it reads are three (!, **BREAKING**, the clause-②-narrowing arm, :673-686). Check Changeset is success on this head after the body edit. The precedent 18.standard-error-code-batch-members-retired.ts is followed in kind: a wire-level narrowing with no stored-metadata rewrite, the entry "the notification channel". The citation at declared-sharing-rules.ts:7-9 now names an entry that exists, so the code comment, the changeset and card #23952 item 2 say one thing. Wording, one correction owed: the reason string's parenthetical "(ADR-0131 D13: each retirement is an ADR-0087 entry)" (18.sharing-rule-declared-mirror-retired.ts:32-33) overstates D13, which says "Each retirement of an authorable shape is an ADR-0087 entry" (docs/adr/0131…:700-701) — and the same sentence rightly says the seeder is machinery, not an authorable shape. The entry's actual warrant is the card's item 2 ("the seeder is machinery, not an authorable shape, so the ADR-0087 entry is the note"). Rewrite the parenthetical to "(card #23952 item 2 chose this entry as the seeder's retirement note; ADR-0131 D13 asks one of every authorable-shape retirement)" — a reason string ships in the generated upgrade guide, so it should quote D13 as D13 reads. Not blocking: it changes no consumer action. Also noted: entries/README.md says a step-18 retirement "still adds its STEP18_RATIONALE fragment" to the template; this PR adds none, neither does the precedent entry, and no gate requires one — not owed, recorded.

(a) deleteRule on a declared rule: 204 → 403 PERMISSION_DENIED. RIGHT — judged in (1).

(b) Grants carry the rule's NAME; the ceremony renames before any mirror deletion (ruling β item 5 (c)). RIGHT. projectDeclaredRule gives the projection id: rule.name (sharing-rule-service.ts:153-169), isDeclaredSharingRule needs managed_by === 'package' AND id === name (:177-179; ids are minted and managed_by is readonly to non-system writers, so unforgeable), and every grant read and write goes through ruleGrantScope (:190-194; call sites :1031, :1065, :1357, :1377, :1988, :2050). An organization-authored rule keeps its row id as source_id (pinned :2198-2207). The ceremony's runDeclaredRuleMirrorDeletion (organization-ownership-apply.ts:2561-2652) works in the ruled order and the order is in the code: mirrors read by the departure's own selector under every organization (:2503-2517), the block on a mirror whose name no package declares while grants still name it with nothing written (:2586-2596), the rename of every source = 'rule' grant from the mirror's id to its NAME (:2598-2615), a verification that zero grants still name any mirror which THROWS before any delete (:2616-2623), then the delete by id with the selector re-asserted and counted to zero (:2626-2642). Pinned: the last rename precedes the first delete in the write log (organization-ownership-apply.declared-rule-mirrors.test.ts:253), every grant resolves (:217-255), idempotent (:257), the checkpointed re-run on a database certified by a seeding release (:266-287 and organization-ownership-apply.ts:2997-3008), the undeclared-with-grants block (:288), the grant-less undeclared mirror goes (:299), the no-registry block (:310), the walled organization-stamped copy goes and the customized one stays (:321), the seeding-release leg stays advisory (:334). A blocked re-run leaves the marker un-verified: the final ledger write carries complete: false (:3097-3099) and recordDataMigrationRun sets verified_at only when passed (migration-flag.ts:198). Through the command (organization-ownership.declared-rule-mirrors.integration.test.ts): a served boot writes zero rows and serves all eleven under their names (:211-225), --apply renames eleven grants, deletes exactly the eleven, certifies [0, true, 0, unattributable] (:243-251), every grant resolves (:260-265), and the guarded served boot of the certified file serves the declared rules beside the two kept rows (:278-282).

(c) The walled posture. RIGHT — judged in (2); the per-organization projection, the organization-stamped grant scope, the creation-time scope add (sharing-plugin.ts:806-823, pinned :2383-2388) and evaluateRule's one pass per projection reported as one rule (sharing-rule-service.ts:1038-1062) all hold.

(d) Same-name shadowing. INHERITED and correctly bounded. The held key (sharing-rule-service.ts:878-892: the name alone under single, name::organization under a wall) reproduces the retired seeder's upsert key, so a stored row of the declared name answers for it exactly where the seeder would have found that row and left it alone (pinned :2209-2222, :2290-2298). The two new consequences the FAIL record measured — an administrator can now CREATE a same-name row where none existed, switching the declaration off in that scope by name, and the shadowed declaration's name-keyed grants stand until the next boot's sweep — are acknowledged in the PR body and carried by decision card #24378 (the claim amendment). Nothing in this round changes that reading; it blocks nothing here.

(e) DECLARED_SHARING_RULE_SEEDER_RUNS = false and decision #24352 → C. RIGHT and consistent. The constant flips in this change and no other (organization-ownership-inventory.ts:676, the hand-off's one order), the mirrors therefore hold certification until the step deletes them, the integration test asserts the constant against what a served boot writes (:211-225). Unattributable rows stay advisory here (organization-ownership-apply.ts:3097, asserted as unattributable at integration :243) — C is executed in stage 8c-0, which 6251599171 orders AFTER this PR's ceremony half and before 8c-1, so β item 2 is met in the two ruled steps.

Other surfaces the diff moves, all RIGHT: GET /api/v1/sharing/rules lists a declared projection with id = name, managed_by: 'package', customized: false, organization-less under single; POST …/:name/evaluate works by name (dogfood org-scoped-sharing-rule-listing); the boot writes zero sys_sharing_rule rows in every posture with the positive control that removing the declaration stops the grant (sharing-rule.test.ts:2181-2196); the hooks, the business-unit recompute, the backfill and the sweep all list through listRules (:844-849; the backfill evaluates each id once, sharing-plugin.ts:200-209); @objectstack/lint's verdicts change prose only ("never served", not "never seeded"), rule ids and severities unchanged; the sharing_rule liveness producer anchors are re-pointed to declared-sharing-rules.ts#translateDeclaredSharingRule and sharing-rule-service.ts#projectDeclaredRule (evidence pointers; Spec property liveness green); resolveDeclaredRuleScopes is exported from the module for its pins and NOT from the package entry (index.ts:103-109), so no public surface widens; DefineSharingRuleInput.managedBy keeps its meaning with no in-repo producer; the runtime declaration-copy test reads the served rules and pins sys_sharing_rule empty.

② Semver level

The changeset bumps @objectstack/plugin-sharing minor, @objectstack/cli minor, @objectstack/lint patch. The LEVELS match what the diff publishes: both runtime packages break (the DELETE refusal and the row-less declared rules; the ceremony deleting the copies and the constant flip), shipped as minor under the launch-window convention check-changeset-no-major.mjs enforces (its header carries the GA end condition, so the convention is in force); lint is prose-only. Breaking-ness is carried three ways, as that gate's header requires during the window: the ! in the summary (:7), the **BREAKING** banner (:20) and the Clause-②: no (narrowing) arm (:9). The Clause-②: line now reads Clause-②: no (narrowing) at the start of a line on BOTH carriers — the PR body (line 2) and the changeset (:9) — so reviewFace reads the narrowing arm, and with the spec entry in the tree the recogniser maps this file list to the schema face; both faces map to the one tier name this record carries. The narrowings are written FROM → TO with remedies on the changeset (:15-16) and in the PR body's first acceptance note, and they agree with the code: DELETE /api/v1/sharing/rules/:idOrName on a declared rule FROM 204 (the seeded row deleted, re-seeded at the next boot) TO 403 PERMISSION_DENIED; sys_sharing_rule FROM one managed_by: package row per declared rule TO none. The ADR-0087 disposition is registered sharing-rule-declared-mirror-retired and the gate re-verifies it (①(3)). Check Changeset is success on this head, re-run after the body edit.

Wrong text, exactly: none on the changeset or the entry that changes a consumer's action. Two corrections for the seat, non-blocking: (i) the entry's D13 paraphrase at 18.sharing-rule-declared-mirror-retired.ts:32-33 (①(3), the rewrite given there); (ii) the PR body's "A7. 2,338 changed lines, measured by dispatch-gates" is the round-0 count — the head is 2,615 (+1,881 / −734, 28 files), which the body's own first paragraph states; the A7 sentence should read 2,615.

③ Boundary flags

Every dev flag, deviation and open_questions entry in the report (6252786437) and the addendum (6253831516), answered or escalated.

  1. not_changed_noted — DeclaredSharingRulesPort.names() answers REGISTRY names, not SERVED names. Measured. The port (organization-ownership.ts:802-819) maps registry.listItems('sharing_rule') (else the metadata service) to names with no translation; the step treats any such name as resolvable (organization-ownership-apply.ts:2576) and renames the mirror's grants to it (:2598-2615). What v18 refuses to translate (declared-sharing-rules.ts:168-222): a declaration with no name or object; a sharedWith.type mapRecipientType does not map or an empty value; a field recipient off FIELD_NAME_GRAMMAR (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); an owner-type rule; a condition the compiler cannot lower, or one that lowers to a match-all (POST /data/sharing/rules 绕过 SharingRuleSchema:criteria 缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896). The retired seeder at the merge base refused the same set and more — every defineRule refusal, the field grammar among them (bootstrap-declared-sharing-rules.ts :220-291 at a2a3777). So in this tree the served set is a superset of the seeded set: no mirror the retiring release's own seeder wrote names a declaration v18 refuses, and the case is NOT reachable from a database last booted on the code this PR replaces. It IS reachable across releases: a mirror an older release's seeder wrote under a translator that accepted what v18's refuses (the 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 grammar, the POST /data/sharing/rules 绕过 SharingRuleSchema:criteria 缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896 guard), with the package still shipping that declaration unchanged — the ceremony then renames the mirror's grants onto a name no projection serves, and the next boot's sweep revokes them, with two log lines (the once-per-rule "declared rule not served" WARN carrying the compiler's reason and detail, and the sweep's revoked count). Whether any released 16.x/17.x seeder differs from this tree's on those refusals is not measurable from this clone (shallow, 586 commits, no release tags); stated as the measurement's limit. Does it block this PR? No. The window is narrow, the access it withdraws belongs to a rule v18 cannot enforce at all (ADR-0049: never served as a match-all, the lint gate reports the condition before boot), the cause is logged by name, and the alternative — a standing mirror answering for the name with criteria frozen at the last seed — is the state D2 retires. Carrier and fix: the adopting seat files a follow-up card (domain:services, with the cli lane): DeclaredSharingRulesPort answers SERVED names. listRules cannot be the port — while a mirror stands it holds the name, so the projection is withheld (sharing-rule-service.ts:857-864, :885-892) — so the read must come from the source: plugin-sharing exports the translated read (translateDeclaredSharingRule or the source's list()), a Clause-②: yes widening of its public surface at minor, and the command's port filters the registry items through it; the cheaper half-step is for the step to WARN, naming the rule and the compiler's reason, when it renames grants onto a name the plugin would refuse to serve. The dev's reading that this needs a public-surface widening is accurate, which is why it is a card and not a patch.

  2. wip: commit subjects without the session trailer pair. The three round commits (7ff2a6f, 1ac7450, e0baaa7) carry neither Claude-Session: nor Co-authored-by:; AGENTS.md says an agent commit ends with the model-free pair, and the pre-push hook (check-commit-card-trailers.mjs) refuses only a model identifier or a card relation, so it did not stop them. A process deviation, outside this record's contract face; the dev was right not to rewrite pushed history on a branch with an open PR under review (§3 condition ⑤ fails, and the rule forbids it outright on a shared branch). Remedy at landing, for the seat: the squash commit's message is the seat's to write and carries the pair; the branch history is not rewritten. Recorded, not blocking.

  3. Absence of a REST-door pin. Judged in ①(1): the door's 403 arm is pinned on the shared handler (sharing-rule-evaluate-inert-object.test.ts:158-170) and on the DELETE route (sharing-rule-org-less-caller.dogfood.test.ts:316-320); the service pin sources the status; a new door test would widen into packages/rest to pin the same handler twice. Not owed here.

  4. Item (b) — switching off a vendor's declared rule (open_questions[0] of the report): decision card [Decision] Declared sharing rules: what switching off a vendor's rule means once they are read from the registry #24378 is filed (claim amendment 6253100453); it blocks nothing here. The card should carry ①(d)'s two measured consequences as evidence, as the prior record asked.

  5. Ruling β item 6 label deviation (open_questions[1]): option A stands — the label names the step and the stage (organization-ownership-inventory.ts:544-548), the card number sits in the adjacent comment (:537-541), check:doc-authoring refuses a tracker id in a runtime string and AGENTS.md states that rule. Recorded on the card by the claim amendment ("recorded and not waived"); the one-line record on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204's thread the prior review asked of the adopting seat is the seat's duty and was not among this record's inputs — it does not gate this PR.

  6. Walled organization-stamped copies deleted too (open_questions[2]): A is right — the departure's selector, card item 4 and D2 all name the population without a NULL qualifier; pinned at organization-ownership-apply.declared-rule-mirrors.test.ts:321. Answered.

  7. Other report items: the two packages/spec liveness pointer files — evidence pointers only, accepted by the claim amendment and Spec property liveness green; the lint, dogfood, runtime and remote-face widenings — each a pin or text on the seeder's behaviour, right; DefineSharingRuleInput.managedBy with no producer — zero-pull, the acceptance note is the carrier; #14491's parse axis unchanged — right; the doc-comment prose still saying "the seeder" in validate-sharing-rule-enforceability.ts — drift, not filed, fine; the Setup Sharing Rules list no longer showing declared rules — the consumer card is Setup → Sharing Rules lists only stored rules once declared rules leave sys_sharing_rule (objectstack C3 stage 9) objectui#12982, named in the PR body; the PR-body edit the dev declined under the single-write rule — performed by the seat, and the body now carries both the Clause-②: no (narrowing) line and the narrowings note; test:repo declared to CI — Test Core is green on the head.

  8. Flagged here, none blocking: (i) the short-page test on the filtered page in resolveDeclaredRuleScopes (sharing-plugin.ts:110-114; ①(2)(ii), with its fix); (ii) the entry's D13 paraphrase (①(3)); (iii) the PR body's stale A7 count (②); (iv) the 204 on DELETE of a declared name under UNKNOWN scopes, consistent with "served nowhere" (①(1)).

Implemented-by: claude/issue-23952-declared-sharing-rules-registry
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 19:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 19:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 5e97907 Oct 18, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23952-declared-sharing-rules-registry branch October 18, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants