Skip to content

plugin-sharing, runtime: sharing_rule under regime C, so every same-name row is refused and declared rules are switched through the activation ledger (ruling A on #24378) - #24428

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-24421-sharing-rule-overlay-guard
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-24421-sharing-rule-overlay-guard

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #24421'; check-partof-closing-keyword with this text as PR_BODY exits 0, and check-closing-keyword-parity exits 0.

Part of #24421
Clause-②: no (narrowing)

This PR carries items 2 and 5 of ruling A on #24378 (comment 6258883445, maintainer 「24378 同意 A」), which supersedes ruling B′. ADR-0131 D6 now charts sharing_rule under regime C: a declared sharing rule is read-only and clonable, and it is switched through the activation ledger. The scope is the claim amendment 6258985416. The ownership-ceremony step (each customised copy becomes a clone plus the base switched off) is a later stage, which carries the card's closing line.

What changed

The guard: every same-name row is refused with 409 RESOURCE_CONFLICT, naming the declared rule. It applies at both write doors.

  • SharingRuleService.defineRule covers POST /api/v1/sharing/rules, the SDK's shares.rules.save and any service caller. A rule created under a declared name is refused whether or not that rule is switched off. The first round's id-addressed projection edit is removed, so there is no customised-copy door. A stored row that already holds a declared name stays editable as itself: that is the upsert path, not a new row.
  • bindDeclaredRuleNameGuard, a beforeInsert / beforeUpdate hook on sys_sharing_rule, covers the /data door. It refuses an insert of a declared name, and an update that renames a row onto one (judged against ctx.previous). System writes pass.
  • DeclaredSharingRuleNameConflictError carries code RESOURCE_CONFLICT and status 409, which is HttpStatusErrorCodeMap[409]. It is catalogued, so nothing is minted. Its message tells the caller to create their rule under another name and to switch the declared rule off through the activation door.

The ledger switch. sys_metadata_activation now records metadata_type sharing_rule (SHARING_RULE_ACTIVATION_TYPE).

  • declaredRules reads the ledger once per call, as readPositionActive does: no ledger object means no read, and a failed read switches nothing off. A declared rule switched off is served in no scope.
  • bindSharingRuleActivationReconcile hooks afterInsert / afterUpdate / afterDelete on the ledger and calls reconcileDeclaredRuleActivation(name) in the same request. Switched off, the rule's name-keyed grants are purged across every scope. Switched on, each projection's one-rule pass re-derives them. When scopes are unknown, nothing is reconciled. A failure is thrown to the door, which answers 5xx. The ledger row is not rolled back: a code-registered hook opens no abort unit, so the grants converge at the next boot sweep or the next flip (contract review 2, ③).
  • The door is the existing catalog activation door, POST /api/v1/security/_activation/:type/:name (packages/runtime/src/domains/catalog-activation.ts), with sharing_rule admitted. It enforces the same two tiers: manage_metadata, then the ADR-0126 §5 posture rule, which makes the switch the platform operator's under a wall. The name is resolved through the sharingRules service (declaredRuleNamed), and the row records the registry item's _packageId (new DeclaredSharingRule.packageId). An undeclared name answers 404. With no service bound, or a failed read, the door answers 503.

A declaration over a stored row. When a package declares a rule whose name a stored row already holds (a package upgrade, or a pre-C customised copy), the row is never treated as an overlay. It answers as its own rule. The declaration is withheld in that scope, and the conflict is logged at error once per name and scope per process, with the remedy (rename the row, and switch the declared rule off if it must not grant).

Dropped: the first round's immediate reconcile on sys_sharing_rule writes, and its delete path. sharing-plugin.ts's rule trigger is back to main's shape, and the id pluck in sharing-routes.ts is reverted.

Tests

  • pnpm --filter @objectstack/plugin-sharing test: 48 files / 1156 tests. typecheck: exit 0.

  • @objectstack/runtime: the 7 activation-door test files, 145 tests. typecheck: exit 0.

  • @objectstack/rest: the sharing-rule test files, 19 tests. typecheck: exit 0.

  • sharing-rule.test.ts, block "regime C", 17 pins:

    • A rule taking the declared name is refused 409 for an org admin and for a system context, with nothing landing. A projection id is refused. A switched-off rule still holds its name.
    • Clone plus base-off revokes the base's grants everywhere in the same write, keeps the clone's grants, and leaves nothing for the boot sweep. Switching it back on re-derives the grants.
    • Another type's ledger row does nothing. A failed ledger read revokes nothing. A switched-off rule cannot be deleted (403).
    • A declaration over a stored row is withheld and reported once, and the row stays editable. Walled, it is reported per organization.
    • The /data door refuses an insert and a rename onto the name.
    • Walled, one ledger row switches the rule off in every organization and back on. Unknown scopes reconcile nothing.
  • catalog-activation-door.test.ts, 6 pins for sharing_rule: switched under single with the package recorded; operator-only under group / isolated; 404 for an undeclared name; 503 with no service; 503 on a failed read; a row with no package.

  • sharing-rule-declared-name-conflict.test.ts: POST /sharing/rules answers 409 nested.

  • Ablations (scripts/ablation-replace.mjs; each leg restored, blob == HEAD, git diff HEAD empty; the tests import src/ relatively):

    Ablated Red
    defineRule throw 5
    the ledger filter in declaredRules 3
    the ledger hook's reconcile call 3
    the declaration-over-row report 2
    the /data hook throw 2
    the door's declaredRuleNamed lookup 3

Gates were run locally at 768fc0b2c: 149 commands, made up of the 101 families dispatch-gates --commands derived plus the artifact-roster block. 145 exited 0. --ran reports 101 derived, 101 run, 0 NOT-MEASURED. The other four need PR or event context: check-closing-target-claim, check-single-claim-paths, check-partof-closing-keyword (exit 0 with PR_BODY set) and check-governed-queue-guard.

Acceptance notes

  • The ADR-0087 marker stays not-required (no-migration-prescription), as the contract review on the first round decided from ADR-0087 D8 and ADR-0131 D13. check:adr-0087-registration accepts it.
  • Files outside the plugin, all within the amended surface or doc-comment only:
    • runtime/src/domains/{catalog-activation,activation-gate}.ts: the activation door.
    • runtime/src/domains/security.ts, dispatcher-plugin.ts, route-ledger.ts: notes now name sharing_rule.
    • content/docs/permissions/system-context.mdx row 40: the guard's isSystem anchor.
  • sys-metadata-activation.object.ts is unchanged. Its metadata_type description reads "('flow', 'permission', …)" and needed no edit.
  • Not in this PR:
    • the ceremony step, the stage that finishes the card;
    • objectui#12982 (Disable and Clone);
    • content/docs/permissions/sharing-rules.mdx, which does not describe regime C yet.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/runtime, touching 47 documentable anchor(s).

46 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 fa26308bb0332152a359335be27ba1a7f18574de.

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 6 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 — 31 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 fa26308bb0332152a359335be27ba1a7f18574de → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fa26308bb0332152a359335be27ba1a7f18574de

⚠️ 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 fa26308bb0332152a359335be27ba1a7f18574de → 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: 81d447db8c359cfdc81b16c7d200ab2967e66371
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/sharing-rule.test.ts, packages/rest/src/sharing-routes.ts, packages/rest/src/sharing-rule-declared-name-conflict.test.ts + packages/spec/src/contracts/sharing-service.ts (DefineSharingRuleInput, ISharingRuleService.defineRule), packages/client/src/index.ts (shares.rules.save, :6459–6476), packages/plugins/plugin-sharing/package.json and src/index.ts, packages/rest/package.json and src/index.ts, packages/rest/src/error-response.ts (classifiedRefusalAnswer), packages/rest/src/rest-hook-refusal-status-passthrough.test.ts, packages/objectql/src/engine.ts (buildSession :6591–6606, hook priority :4747–4754, previous binding :16859), packages/spec/src/data/hook.zod.ts (HookContextSchema.previous :647), packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts, packages/plugins/plugin-sharing/src/rule-hooks.ts (bindRuleCriteriaGuard), packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/spec/src/migrations/types.ts, packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, docs/adr/0087, docs/adr/0112, docs/adr/0131 (at origin/main and the D6/D7 notes at refs/review/pr-24422 = 2261458d1), .changeset/24421-sharing-rule-declared-name-guard.md and content/docs/permissions/system-context.mdx (prose faces, read for the claims the code makes about them)

Head verified against GET /pulls/24428 twice during the review: unchanged at 81d447db8. Diff read as git diff origin/main...refs/review/pr-24428 (merge-base 71fa166e9, 5 commits, 7 files, +516/−7). Inputs: card #24421 body and both comments (claim 6257564383, os-dev-report 6258334620), ruling B′ 6257388265 verbatim, the ADR-0131 D6 note at PR #24422's head, PR #24428 body, file list, the one docs-drift comment, zero review comments, the check-runs on the head.

Check-runs on the head (their conclusions are the gate verdicts): 40 runs. 34 success (Lint & Repo Gates with all its lanes, TypeScript Type Check with all lanes, Check Changeset, Check PR Size, Governed Surface Queue Guard, single-claim, closing-target, Part-of, Spec property liveness, Build Core, Build Docs, Test Core 1/6, 5/6, 6/6, Dogfood Regression Gate, Check Documentation Links, Flag docs affected, Auto Label, filter). 5 skipped (Packed-tarball smoke opt-in, Console Pin Gate, Dogfood Verify CLI, Dogfood matrix shard, Temporal Conformance). 1 still in_progress at the time of this record: Test Core (3/6) (Test Core 2/6 and 4/6 completed success between my first and last poll). No run concluded failure. The dev's local report at this head: plugin-sharing 48 files / 1152 tests, rest 9 files / 684 tests, both typechecks exit 0, 145 of 149 derived gate commands exit 0 with the four remaining ones needing PR/event context that CI now supplies green.

① Derived judgments

Every accept-set and public-surface change the diff makes, read in the cited code and judged:

  1. SharingRuleService.defineRule(input: DefineSharingRuleWrite, …) (sharing-rule-service.ts:193–196, :749). The parameter type gains an optional id. A parameter widening is non-breaking; DefineSharingRuleWrite reaches the published d.ts structurally through the exported class but is not exported by name from src/index.ts (the index exports SharingRuleService and the spec's DefineSharingRuleInput only). Right as a change; the unexported alias is a boundary flag (③ item 2), not a defect.

  2. defineRule's accept-set narrowing (sharing-rule-service.ts:850–859). Placed after the existing-row upsert branch, so it fires only when no stored row holds the name in the caller's scope. Then: declaredRuleNamed(name) — if a declaration holds the name, the write is allowed only when input.id === declared.name and declaredRules(context, { name }) is non-empty (the projection is served in the caller's scope); otherwise DeclaredSharingRuleNameConflictError(declared, 'create'). Right against B′ item 2. A system context is not exempt here (pinned: BOOT without id is refused). That is safe: the repo's only non-test caller of .defineRule( is packages/rest/src/sharing-routes.ts:705; no boot or seed path calls it (the seeder retired in stage 9), so no platform write is broken by the guard.

  3. bindDeclaredRuleNameGuard (sharing-rule-service.ts:247–277): beforeInsert/beforeUpdate hooks on sys_sharing_rule, priority: 100 (lower runs first, engine.ts:4753 — alongside bindRuleCriteriaGuard's 100 and ahead of the provenance stamp's 150), skipped for session.isSystem. Insert of a declared name is refused; an update is refused only when data.name is a string, differs from ctx.previous.name, and the new name is declared. ctx.previous is the published HookContextSchema contract bound on update and delete (hook.zod.ts:647; engine.ts:16859 binds it in both phases for both update shapes), and session.isSystem is propagated by buildSession (engine.ts:6606). Bound at sharing-plugin.ts:876 inside the same registerHook && unregisterHooksByPackage block as the criteria guard, so wherever Setup's New is guarded against match-all criteria it is guarded here too. Right.

  4. DeclaredSharingRuleNameConflictError (sharing-rule-service.ts:214–230): code: 'RESOURCE_CONFLICT', status: 409, rule = the vendor rule's name; the message names the rule and its object and says how to customise it. ADR-0112: RESOURCE_CONFLICT is a catalogued StandardErrorCode and HttpStatusErrorCodeMap[409], so no ledger row is minted and error.code stays closed at both doors. Right. The class is not exported from src/index.ts; consumers can read the shape (code/status/rule), which is also what the REST arm reads. Acceptable; noted in ③.

  5. POST /api/v1/sharing/rules plucks body.id (sharing-routes.ts:698). The route's field-by-field pluck drops unknown keys, so without this line the service could never see the edit signal; the only new accepted key is the one the ruling needs. The 409 reaches the wire through handleError's classified arm (sharing-routes.ts:630–633 → classifiedRefusalAnswer, error-response.ts:670–683: declared status below 500 plus a string code → nested envelope at the declared status). Pinned at the HTTP level by the new REST test. Right.

  6. No PATCH or PUT /sharing/rules route exists on main or at the head (grep of method: in sharing-routes.ts: GET, POST, GET/:id, DELETE/:id, POST/:id/evaluate). The card's "POST/PATCH" acceptance is met on the surface that exists; the dev measured and stated A1's falsification rather than inventing a route. Right.

  7. The trigger (sharing-plugin.ts:470–486, :516–523): scheduleReconcile takes the names the write touched — the pre-image's and the written row's, each with its organization_id — and awaits reconcileDeclaredRuleName for each before the row's own revokeRuleGrants/evaluateRule, inside the existing ruleRebindChain and inside the awaited after-hook, so it completes in the write's request. Right.

  8. reconcileDeclaredRuleName (sharing-rule-service.ts:1049–1059): scope is the held key's (null under single, the row's organization under a wall; returns early on unknown scopes or an organization outside the enumeration); if the declaration is still served in that scope → evaluateResolvedRule(projection), else → purgeGrants(ruleGrantScope(projectDeclaredRule(declared, scope))). ruleGrantScope keys declared grants by source_id = name plus the organization under a wall (:284–288), so a wall purge touches one organization's grants only. Right.

  9. declaredRuleNamed (:1026–1029) is scope-agnostic. Consequence: in the degraded state where organization enumeration failed (declaredScopes === null), a declared name is refused in every organization including the edit (declaredRules returns [] so editsProjection is false). Conservative, and it matches the boot sweep's own rule for unknown scopes (hold everything). Right; noted.

(a) The guard.

  • A create taking a declared rule's name is refused at the service (pinned for an org admin and for a system context: { code: 'RESOURCE_CONFLICT', status: 409, rule: DECLARED.name }, HttpStatusErrorCodeMap[409], nothing lands, grants untouched) and at POST /api/v1/sharing/rules (pinned: HTTP 409, body.error.code === 'RESOURCE_CONFLICT', success: false). A rename cannot happen through the service (it upserts by name); through the /data door a rename onto a declared name is refused by the beforeUpdate hook (pinned at the hook: code + status). The refusal names the vendor rule (rule field, pinned; message, not regex-pinned, as the card demands). PATCH: no such route exists.
  • The edit of the projected row stays open: defineRule({ ...rule, id: DECLARED.name, active: false }) stores the organization's copy (pinned: copy.id is minted, not the name; listRules lists the copy alone; a later save without id upserts it). Through the /data door the edit is not available and never was after stage 9: the projection is not a row (GET /data/sys_sharing_rule/{name} has nothing to return) and an insert of the name is refused. So the one edit door is POST /api/v1/sharing/rules with id (and any service caller). That is "as today" on the REST surface, narrowed from "same-name POST" to "same-name POST carrying the projection's id".
  • How an edit is told from a New: input.id === declared.name. The signal is a bare string any client can supply, so it is intent, not proof — a script that sends id: name is admitted. That is correct under B′: the write it admits is by definition "an edit of the projected declared row" (the id is the id GET /sharing/rules lists), the authorisation is assertCanManageRules as before, and the write the ruling closes — the accidental New that did not know the name was taken — is closed. Bypasses checked: /api/v1/data/sys_sharing_rule insert → hook refuses (non-system); /data update → refused only on a rename onto the name, a same-name save or a rename away passes (pinned); import → rows go through the engine's per-row beforeInsert under the caller's context (engine.ts:5112, the 数据导入:批量 insert 给 Hook 的输入形状与单条不一致(installFlatInput 失效);「运行自动化与触发器」开关是摆设且默认值应为选中 #2922 ruling) → refused; batch → per-row likewise; package install → after stage 9 nothing installs sys_sharing_rule rows (the repo's only insert is defineRule's, :879; an unmanaged install copies definitions into the metadata ledger, not rows); system context → /data hook passes isSystem by design (ownership ceremony, migrations), while defineRule under a system context is still guarded.
  • Not covered, and whether B′ requires it: (i) system-context /data writes — B′ item 2 says "Setup's New (or any write that is not an edit of the projected declared row)"; the platform's own machinery is not the write the ruling describes, the skip is documented at system-context.mdx row 40, and the sibling guards (bindRuleCriteriaGuard runs for everyone; the provenance stamp skips system) show both conventions coexist. Acceptable. (ii) The /data door's HTTP status for this refusal is not pinned in this PR — only the thrown shape is; it rides the generic status passthrough of mapDataError pinned by rest-hook-refusal-status-passthrough.test.ts ([rest] Hook refusals carrying an explicit statusCode are not mapped by /api/v1/data — they leak as HTTP 500 INTERNAL_ERROR with no located guidance #7525). The card's HTTP-status acceptance names the service and /sharing/rules, which are pinned. Acceptable.

(b) The reconcile. The copy's creation reconciles the declaration's grants in the same request (pinned: after afterInsert, both organizations' declared grants are gone under single and sweepOrphanedRuleGrants() returns 0 — "before the next boot" observed). An active change on the copy reconciles again (pinned: a stale declared grant goes on afterUpdate through the /data door shape). Ordering is pinned with an injected failure: evaluateRule is never called, the declaration's grants stay, a warn is logged — so the two grant sets are never both left in force by one write. Nothing else purges or re-materialises unexpectedly: the reconcile runs only for names a declaration holds (declaredRuleNamed null → return), and the one addition beyond B′'s letter — the copy's deletion re-derives the declaration's grants at once (pinned) — restores exactly what item 1's model says returns when the overlay is dropped. The residual window the PR body admits (a record write landing between rebind and reconcile within one awaited chain) is the pre-existing #4433 shape, not widened here.

(c) Scopes. Under a wall the overlay is the organization's own: pinned (org_a's copy revokes org_a's declared grants only; org_b keeps its projection and grant; sweep 0). Under single the one overlay answers everywhere: pinned (both organizations' declared grants go). The key is the held key at sharing-rule-service.ts:991–998 (name under single, name::org under a wall) — today's holder key the ruling cites at :887, moved only by line drift. Both pinned.

(d) Pins. 13 in sharing-rule.test.ts (block "regime O, guarded", on the file's existing pinned engine double; wireTrigger binds the real plugin handler and the real service and fires the hook contexts by hand — the file's established pattern) and 3 in the new REST file. Every refusal pin asserts code + status (+ rule) and HttpStatusErrorCodeMap[409], never a message regex; the REST pins assert the HTTP status and error.code, with a preservation pin (body.id reaches defineRule) and an anti-vacuity pin (an ordinary fault is still 500 RULE_DEFINE_FAILED). Reverse-verified: three ablations reported with each leg restored (defineRule throw → 4 red; the trigger's reconcile call → 5 red; the /data hook's throw → 2 red), so each pin family has a red leg. Not re-run here (read-only).

② Semver level

  • Changeset. .changeset/24421-sharing-rule-declared-name-guard.md: @objectstack/plugin-sharing: minor, @objectstack/rest: patch; summary fix(plugin-sharing)!:; **BREAKING** (an accept-set narrowing), shipped as minor under the launch-window convention; Clause-②: no (narrowing); the narrowing stated as a FROM → TO pair (FROM 201 / 200 with the new row replacing the vendor rule, TO 409 RESOURCE_CONFLICT, naming the declared rule) with the one-line fix (save the vendor rule back with its id; otherwise another name); the unchanged set and the reconcile stated beside it. This is the AGENTS.md §3 shape exactly: (narrowing) is BREAKING, the launch window (check-changeset-no-major.mjs, ADR-0087's 2026-09-13 level amendment) ships it minor, the FROM → TO pair and the fix are present, and one ADR-0087 marker is present. Check Changeset concluded success. Right. Observation, not blocking: @objectstack/rest's POST door now accepts a new body key, which read alone is a widening of a published request; the lockstep fixed group makes the group's effective level the minor already graded, so nothing moves on it.

  • ADR-0087 marker — decided from the ADR's text: not-required (no-migration-prescription) is right; do not register. ADR-0087's ledger serves metadata upgraders — D8 (the 2026-08-30 addendum): "The ADR-0087 ledger serves metadata upgraders: objectstack migrate meta rewrites stored metadata", and no-migration-prescription "is not wrong in general … it simply cannot tell a prescription for a metadata upgrader from a prescription for a source-code consumer, and only the first is the ledger's business." D3 and the launch-window ratification owe "one D3 semantic entry per retirement family"; ADR-0131 D13 says "each retirement of an authorable shape is an ADR-0087 entry". This PR retires no authorable shape and moves no metadata: a stack's sharingRules is authored exactly as before, no spec key, export, option or stored shape changes, and the FROM → TO is an HTTP accept-set at one write door whose fix is a request key. Stage 9's entry (18.sharing-rule-declared-mirror-retired) was owed because it retired the seeder — D13's clause; that clause does not reach a guard. The ADR itself says what registering here would do (sixth-category table, the registered row): "writing one would put a prescription in the ledger that objectstack migrate meta, spec-changes.json and the upgrade guide cannot project. False data in the one ledger this whole mechanism keeps true." The dev's worry — that the gate's acceptance "rests on its prescription detector not reading this body's fix line" — is answered by reading, not by the detector: the body carries no metadata rewrite (no old-spelling → new-spelling of anything a stack author writes), so the detector's silence is a true negative, not the fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 miss. Open question 1 → A. The marker's why text is accurate.

  • Clause-②: no (narrowing) is the right arm. A write that returned 201/200 is refused 409; the PM protocol allows one arm and the narrowing dominates the subordinate widening (id), whose minor requirement the plugin-sharing grade already satisfies.

  • Fixes #24421 is right. Acceptance, item by item: the guard — refused through the service and through POST /api/v1/sharing/rules, with the vendor rule named, code and HTTP status pinned (PATCH measured non-existent); the edit path — open, pinned; the reconcile — creation and active change reconcile in the same request, pinned before the next boot (sweepOrphanedRuleGrants() === 0); scopes — both pinned; size — 523 lines (+516/−7), about half tests, against "about 0.3–0.5k"; Clause-② measured by the dev as a narrowing. The ADR-0131 D6 note at PR docs(adr): ADR-0131 D6 charts sharing_rule under regime C; D7 ledger reading and lines for #24377 and #24435; D14 records the 18.0 scope #24422's head ("Both are built by plugin-sharing: refuse a new or renamed sys_sharing_rule row that takes a declared rule's name, and reconcile the shadowed declaration's grants on a same-name write (ruling B′ on #24378) #24421 … no activation-ledger type … same-name rows are not refused as a class") describes this diff; its sentence "The row is made by editing the declared rule's projected row in Setup" is forward-looking on the Setup half (③ item 4).

③ Boundary flags

The report's four open_questions:

  1. ADR-0087 marker → A, keep not-required (no-migration-prescription). Decided in ② from ADR-0087 D8, D3 and ADR-0131 D13. Recommendation B would register false data; declined.

  2. The id edit signal is undeclared on the spec contract and the SDK → escalate to the seat as a follow-up card (option A); not blocking this PR. It is more than contract tidiness: ISharingRuleService.defineRule is typed on DefineSharingRuleInput (packages/spec/src/contracts/sharing-service.ts:508–526, :579) with no id, and the SDK's shares.rules.save parameter type (packages/client/src/index.ts:6459–6469) has no id either — so a consumer typed against either published surface cannot express the one write B′ makes the customisation door without a cast, while the REST body accepts it. The plugin's DefineSharingRuleWrite and DeclaredSharingRuleNameConflictError are not exported from plugin-sharing's src/index.ts. Both surfaces are off this card's file surface and packages/spec is governed, so the fix is its own card: add id?: string ("the id of the rule this write edits — a declared rule's projection is addressed by its name") to DefineSharingRuleInput and to the SDK save type (a Clause-②: yes widening, minor), and export the two plugin symbols. It should land before objectui#12982 is built on the SDK, or that card uses raw REST.

  3. The delete reconcile → A, keep. It is the same name-and-scope path, it is pinned, and it closes the mirror-image under-grant window (overlay removed → vendor rule's access back at once) that B′ item 1's model implies; it purges nothing.

  4. Setup has no edit door for a projection → A, the epic seat acts on objectui#12982. What the server leaves: exactly one usable door. GET /api/v1/sharing/rules lists each served declaration as a row with id === name and managed_by: 'package' (projectDeclaredRule, isDeclaredSharingRule); GET /api/v1/sharing/rules/{name} returns that one projection (getRule :1061–1068); POST /api/v1/sharing/rules with { id: name, name, label, object, criteria, recipientType, recipientId, accessLevel, active } stores the customised copy and answers 201; later saves upsert by name. Setup's existing Sharing Rules nav item is the generic sys_sharing_rule object view (sharing-plugin.ts:621), which cannot do it: projections are not rows, and /data insert of the name is refused 409. Therefore objectui#12982 must build its card's shape A: one list read from GET /api/v1/sharing/rules (no two-source merge); Edit on a row with id === name && managed_by === 'package' POSTs the form back with id set; Edit on a stored row saves as today; New never offers a declared name and surfaces the server's 409 (error.code === 'RESOURCE_CONFLICT'; the message names the vendor rule); switch-off is Edit with active: false. Server gap that card will hit, for the seat: the copy defineRule stores carries managed_by: 'admin', customized: false (sharing-rule-service.ts:861–876), and bindRuleProvenanceStamp stamps customized: true only on an update of a package/platform row (sharing-rule-provenance.ts:76–78), which a projection never is. So once the copy holds the name, no server read distinguishes "customised declared rule" from "organization-authored rule" — the objectui card's "shows once, as customised" acceptance has no server answer, and that card says such a gap belongs to the server. Not in B′'s letter; a follow-up (stamp the copy at the edit door, or expose a marker on the list), not a blocker here.

The two out_of_scope_findings:

  • Hand-written docs. content/docs/permissions/sharing-rules.mdx does not describe the guard or the id-addressed edit; the Docs Drift Check lists 29 hand-written pages naming touched anchors. The one docs edit in the diff (system-context.mdx row 40) is correct and required by check:system-context-census. A docs-only follow-up; release-owned pages stay untouched. Not blocking.
  • Setup's edit door. Answered in item 4 above.

Further flags, none blocking:

  • System-context /data writes bypass the hook by design (documented at system-context.mdx row 40); defineRule under a system context remains guarded.
  • declaredRuleNamed is scope-agnostic: with organization enumeration unknown, every write of a declared name — the edit included — is refused until the next boot. Conservative and consistent with the sweep's hold rule.
  • The 409 message says "save it with id" even when the caller did send the id but the projection is not served in its scope (a second organization under single). The message is not pinned; cosmetic.
  • @objectstack/rest graded patch while its POST door accepts a new key; immaterial under the lockstep group (see ②).

Implemented-by: claude/issue-24421-sharing-rule-overlay-guard
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

This record was rendered by an isolated at-tier reviewer subagent of the adopting seat (epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3), on the inputs the brief named and nothing else; the seat adopts it. One check-run (Test Core (3/6)) was still in_progress when it was written; every completed run concluded success or skipped.

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Ruling-pointer · the landing stays held; rework under A · #24378 re-ruled A at 6258883445 (batch #368 item 1, the maintainer's 「24378 同意 A」; supersedes B′) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-19T03:19Z

This PR implements ruling B′ (overlay kept; guard on New and rename; immediate reconcile). Ruling A retires the overlay: keep the guard and widen it to every sys_sharing_rule row that takes a declared rule's name in its scope (the projection-edit path included); add the sharing_rule type to sys_metadata_activation as the switch-off (deployment-level, operator-gated under a wall; the resolver reads it as it reads positions and permission sets); drop the reconcile; the ceremony migrates existing customised copies to clone + base-off (D10 fate 2). Terms on #24378 and #24421.

…the ledger switch for declared sharing rules

Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP
Co-authored-by: Claude <noreply@anthropic.com>
… ledger switch and its reconcile, the declaration-over-row report

Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP
Co-authored-by: Claude <noreply@anthropic.com>
…_rule on the activation route's ledger note

Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP
Co-authored-by: Claude <noreply@anthropic.com>
…activation switch

Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 768fc0b2c1a03dba28e9dc01267aab5fab49f2f3
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/sharing-rule.test.ts, packages/rest/src/sharing-rule-declared-name-conflict.test.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/domains/activation-gate.ts, packages/runtime/src/domains/catalog-activation-door.test.ts, packages/runtime/src/domains/catalog-activation.ts, packages/runtime/src/domains/security.ts, packages/runtime/src/route-ledger.ts + packages/platform-objects/src/system/sys-metadata-activation.object.ts (the ledger's object, unchanged), packages/spec/src/contracts/sharing-service.ts (DefineSharingRuleInput, ISharingRuleService, unchanged), packages/plugins/plugin-sharing/src/index.ts and package.json, packages/runtime/src/index.ts and package.json, packages/rest/src/sharing-routes.ts (verified byte-identical to origin/main), packages/rest/src/index.ts and package.json, packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts, packages/core/src/utils/metadata-activation-store.ts (ObjectStoreMetadataActivationStore.setActive), packages/core/src/security/resolve-authz-context.ts (readDisabledCatalogNames), packages/objectql/src/engine.ts (triggerHooks, the after-hook contexts, opensAfterAbortUnit), .changeset/config.json, scripts/check-changeset-no-major.mjs, scripts/check-changeset-fixed.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-partof-closing-keyword.mjs, docs/adr/0087, docs/adr/0131 (at origin/main and the rewritten D6 note plus the D7 ledger note at refs/review/pr-24422 = 371b98aec), AGENTS.md (§3 changesets, Prime Directive #14), and the prose faces read for the claims the code makes about them: .changeset/24421-sharing-rule-declared-name-guard.md, content/docs/permissions/system-context.mdx, content/docs/permissions/sharing-rules.mdx, content/docs/permissions/authorization.mdx, content/docs/concepts/metadata-lifecycle.mdx

Head verified against GET /pulls/24428 at the start of the review: 768fc0b2c, unmoved from the brief. Diff read as git diff origin/main...refs/review/pr-24428-r2 (merge-base 71fa166e9, 11 commits, 13 files, +782/−20) and the round-2 delta git diff 81d447db8 refs/review/pr-24428-r2 (13 files, +506/−253). Inputs: card #24421's body and all five comments (claim 6257564383, round-1 report 6258334620, ruling pointer 6258902488, claim amendment 6258985416, round-2 report 6259758546), ruling A 6258883445 verbatim, the ADR-0131 D6 and D7 notes at PR #24422's head, PR #24428's body, title, file list, its three issue comments (docs-drift 6258327562, the round-1 record 6258643741, the ruling pointer 6258917942), zero review comments, zero reviews, and the check-runs on the head.

Check-runs on the head (their conclusions are the gate verdicts): 41 runs, every one completed. 36 success (Lint & Repo Gates and all its lanes incl. Lint · CI, release & engine gates and Lint · PM dispatch-gates, TypeScript Type Check and its four lanes, Check Changeset, Check PR Size, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, Spec property liveness, Build Core, Build Docs, Test Core and its six shards, Dogfood Regression Gate, Check Documentation Links, Flag docs affected, Auto Label, filter). 5 skipped (Packed-tarball smoke opt-in, Console Pin Gate, Dogfood Verify CLI, the Dogfood matrix shard, Temporal Conformance). 0 failure, 0 still running. No legacy commit statuses. Note that the two closing-line gates passed against the PRESENT body (Fixes #24421); they re-run on the body edit the seat owes (③).

① Derived judgments

Every accept-set, ledger and public-surface change the diff makes, read in the cited code and judged against ruling A items 1, 2, 5, 6, the claim amendment and the D6 note:

(a) The widened guard — every same-name row refused, new or renamed; the round-1 id door gone.

  • Service. SharingRuleService.defineRule (sharing-rule-service.ts:778–882): after the existing-row upsert branch, declaredRuleNamed(input.name) → DeclaredSharingRuleNameConflictError(declared, 'create') (:879–882). There is no id escape any more: DefineSharingRuleWrite, editsProjection and every read of input.id are gone from the tree (git grep at the head: zero hits), and the parameter type is the spec's DefineSharingRuleInput again. A system context is not exempt (pinned: BOOT refused). A switched-off declaration still holds its name (declaredRuleNamed reads the declared source, not the ledger — :1114–1117; pinned). Pinned negatively that the projection's id buys nothing. Right against item 2.
  • POST /api/v1/sharing/rules. Reaches defineRule; sharing-routes.ts is byte-identical to origin/main (the body.id pluck reverted — the r2 delta shows the removal, the net diff has no hunk). The 409 reaches the wire through handleError's classified-refusal arm, pinned at HTTP level (409, error.code === 'RESOURCE_CONFLICT', HttpStatusErrorCodeMap[409], success: false) plus the anti-vacuity 500 RULE_DEFINE_FAILED. No PATCH/PUT route exists (unchanged from round 1). Right.
  • The /data door. bindDeclaredRuleNameGuard (:231–264): beforeInsert / beforeUpdate on sys_sharing_rule, priority 100, skipped for session.isSystem; an insert of a declared name is refused, an update only when data.name is a string, differs from ctx.previous.name and is declared (a same-name save and a rename away pass — pinned). sys_sharing_rule declares no enable.apiMethods restriction, so the generic data API is open and this hook is its guard. Import and batch run the per-row beforeInsert under the caller's context (round 1's engine finding, unchanged); package install writes no rows after stage 9. Bound at sharing-plugin.ts:857 inside the same registerHook && unregisterHooksByPackage block as the criteria guard. Right.
  • Scope of the refusal. declaredRuleNamed is scope-agnostic, so a declared name is refused in every organization, under unknown scopes included — wider than "in its scope", conservative, and consistent with the sweep's hold rule. Acceptable; noted.
  • What is not refused, by design: a STORED row that already holds a declared name (a pre-C customised copy, or an organization's own rule a package later collided with) stays editable through defineRule's upsert branch and through a same-name /data save — neither is a new row nor a rename onto the name, which is the ruling's exact predicate. Pinned ("the stored row stays editable as itself"). Open question 2 → see ③.

(b) The ledger switch — sys_metadata_activation type sharing_rule.

  • Stops serving. declaredRules (:994–1035) reads readSwitchedOffDeclaredRules() after the object/active/name filters and before the holder read; an off name is filtered out, so it is served in NO scope (the row is deployment-level). Pinned under single (listRules lists the clone alone) and under a wall (listRules → [], grants gone in both organizations, one row). Right against item 1.
  • How it is read. readSwitchedOffDeclaredRules (:1067–1088): registry check first (a composition without the ledger object issues no read), one find on METADATA_ACTIVATION_TABLE with filter: { metadata_type: 'sharing_rule' }, active === false || active === 0 reads off, no row means on, a failed read warns and switches nothing off for that pass. That is the shape of the plugin's own readPositionActive (:2104–2125), which documents itself as the resolver's read (readDisabledCatalogNames, core resolve-authz-context.ts:983–1009: the same registry check, the same type-and-name key, the same false || 0 reading). One deliberate difference from core: core's tryFind THROWS on a failed read (authz fails closed), the sharing reads treat it as active (a sharing switch fails open, so a read outage is never a mass revocation) — the posture readPositionActive already states for positions. Right against item 1's "read the way the resolver reads positions and permission sets"; the failure-mode choice is flagged in ③, not blocking.
  • Grants purged and re-derived in the same request. bindSharingRuleActivationReconcile (:282–300): afterInsert / afterUpdate / afterDelete on the ledger, priority 200, row = { ...previous, ...(result ?? input.data) }, type-guarded on SHARING_RULE_ACTIVATION_TYPE, calls reconcileDeclaredRuleActivation(name) (:1130–1140): unknown scopes or an undeclared name → nothing; declaredRules(SYSTEM_CTX, { name }) empty → purgeGrants({ source: 'rule', source_id: name }) (every scope; only the NAME-keyed grants — a stored row's grants carry its srule_… id and are untouched, pinned: the clone's grants stay); otherwise evaluateResolvedRule(projection) per served scope. The engine's contexts match the handler's reading: afterInsert carries result = the inserted row (engine.ts:16252); the per-row afterUpdate carries previous = the pre-image and result = row+patch (:5165–5172), so metadata_type and name come from previous when the door's update patch carries only { id, active, package_id }; afterDelete carries previous and no result/input.data (:5169–5172) → treated as on → re-derive. Pinned: off purges everywhere and leaves the sweep nothing; on re-derives (p_red, p_red_2; walled a_red/org_a, b_red/org_b); another type's row of the same name does nothing; a failed ledger read revokes nothing; unknown scopes reconcile nothing. Right against the amendment item 2.
  • Deployment-level, operator-gated. The ledger object is tenant-less (systemFields: { tenant: false }, unique (metadata_type, name) 'global') and enable.apiMethods: ['get', 'list'] — the generic data API cannot write it. The door (handleCatalogActivationWrite, catalog-activation.ts:168–335) runs BOTH authority gates first, before shape, body and lookup: refuseUngrantedActivationAuthoring (manage_metadata) then refuseUngrantedActivationWrite (ADR-0126 §5: inert under single, the platform-admin RUNG under group/isolated) — the same two tiers as the flow and action doors (activation-gate.ts docblock). SHARING_RULE_ACTIVATION_SUBJECT supplies only the refusal sentence (names the operator; names the organization's own rules as still theirs, D3). Pinned: single tenant admin → 200 with package_id: 'crm' recorded; group and isolated tenant admin → 403 PERMISSION_DENIED, zero writes, then the operator → 200 and one write. Right against items 1 and 2 ("as for flows and actions").
  • Which door writes it, who may call it. POST /api/v1/security/_activation/sharing_rule/:name (the catalog door, sharing_rule admitted at catalog-activation.ts:79–84, 94–97). The name is resolved through deps.resolveService(context, 'sharingRules') → declaredRuleNamed (the service plugin-sharing registers at sharing-plugin.ts:775), duck-typed: no service → 503 SERVICE_UNAVAILABLE, a throwing read → 503, an undeclared name → 404, each with no write (all pinned). The write is ObjectStoreMetadataActivationStore(ql, 'sharing_rule').setActive → engine.update / engine.insert under SYSTEM_CTX (core metadata-activation-store.ts:260–288), so the ledger hooks fire; package_id comes from the new DeclaredSharingRule.packageId (declared-sharing-rules.ts:160, translated from the registry item's _packageId at :227), absent for an unpackaged declaration (pinned: package_id null). Callers: holders of manage_metadata; under a wall, the platform operator only. Right.
  • Failure order. A thrown reconcile propagates: triggerHooks awaits each handler with no catch (engine.ts:4993–5025) → the store → the door's errorFromThrown(err, 503). ⚠️ The ledger row is NOT rolled back on that path: opensAfterAbortUnit opens a unit only for metadata-bound hooks (entry.meta), and a code-registered hook has none (:5082–5086). So a reconcile failure leaves the row flipped and the door answering 5xx; the grants converge at the next boot (sweepOrphanedRuleGrants reads declaredRules, which the ledger now filters, so the switched-off rule's name-keyed grants are orphans) or at the next flip. Not pinned. The owed body's sentence — "A failure is thrown to the door, so it never sits behind a successful flip" — is true of the RESPONSE, not of the row. The same class as the pre-existing sharing: deactivating a rule never withdraws its materialized grants — not on touch, not at boot #4433 shape, not widened; flagged in ③, not blocking.
  • Not pinned end-to-end: the door test's engine double records writes without dispatching hooks, and the plugin pin fires the hooks by hand (wire().setActive, modelled on the store's write). The seam is the engine's hook dispatch, a pre-existing contract. Acceptable.
  • The object. metadata_type is Field.text (maxLength 100), not an enum: sharing_rule needs no declaration edit, so leaving sys-metadata-activation.object.ts untouched is right (the amendment admitted it "only if its type text must name sharing_rule"). Its docblock still names flows, actions, positions and permission sets as the ledger's family; the D7 note at docs(adr): ADR-0131 D6 charts sharing_rule under regime C; D7 ledger reading and lines for #24377 and #24435; D14 records the 18.0 scope #24422 makes sharing rules the fifth reader. Doc-comment follow-up (③).

(c) Loud at load — a declaration over an authored row.

  • declaredRules (:1023–1026) → reportDeclarationOverAuthoredRow (:1047–1058): once per name::scope per process, logger.error (falls back to warn), the message names the rule, the organization, the consequence (not in force there; the row answers as itself) and the remedy (rename the row → it is a clone; switch the declared rule off through the door). The boot path reaches it: sharing-plugin.ts:826 lists { activeOnly: true } under a system context → declaredRules(SYSTEM_CTX, …) → declaredScopesFor with no caller org returns every scope → every held name::scope is reported at load, per organization under a wall. Pinned: single (one error, meta { rule, organization: null }, a second listing adds none; the declaration grants nothing — the sweep revokes its two grants — and the row stays editable); walled (meta { rule, organization: 'org_a' } only; org_b keeps its projection).
  • Never made an overlay. No reconcile on the row's write, no customised-copy door, no id signal, no customized semantics: the row is the organization's own rule and the declaration is withheld in that scope. On the wire this is main's held behaviour plus the error (so no FROM→TO is owed for it — ②). Whether "never made an overlay" means withhold-and-report (A) or serve-both (B) is open question 1 → ③: A is right.
  • One nil-consequence edge: a declaration whose own active is false over a stored row is filtered by the boot listing's activeOnly before the held check, so it is first reported on an unfiltered listing. It grants nothing either way.

(d) The immediate reconcile — dropped, nothing stale.

  • sharing-plugin.ts net diff vs origin/main is the import line and the two bind lines only; the write trigger (scheduleReconcile(ruleId, deleted), revokeRuleGrants on delete, evaluateRule otherwise) is main's. reconcileDeclaredRuleName and the names map are gone (git grep: zero hits). sharing-routes.ts is main's. The delete-path reconcile went with it: a pre-C copy's deletion leaves the declaration to be served again at the next read and its grants re-derived at the next record write or boot — main's behaviour, which the ceremony stage (ruling item 3) retires by migrating the copies. Right under regime C: nothing shadows a declaration by design, so there is no shadowed declaration to reconcile; the one switch that moves grants is the ledger's, and that reconcile is in place (b).
  • No "regime O" or B′ wording survives in code or the changeset; the system-context.mdx row-40 text ("a new row or a rename taking a declared rule's name, 409 — defineRule makes that check itself") is still exact.

(e) Pins and ablations.

  • 17 pins in sharing-rule.test.ts block "regime C" (13 single, 4 walled, on the file's pinned engine double; wire() binds the real hooks with the real service), 7 its in catalog-activation-door.test.ts for sharing_rule (the dev's "6" counts the two-posture loop once), 2 in the REST file (the id preservation pin went with the pluck, correctly).
  • Refusals assert { code, status, rule } and HttpStatusErrorCodeMap[409]; the door pins assert statusOf/codeOf; REST asserts the HTTP status, error.code, the map and success. Two message reads, neither a regex on the sentence: the door's 403 pin adds messageOf(refused).toContain('a declared sharing rule') for the SUBJECT wording, the file's own pattern (lines 219, 240–244, 290, 302–303, 365) and in addition to code+status; the deleteRule 403 pin reads message.split(':')[0] === 'PERMISSION_DENIED' because that error is main's plain Error('PERMISSION_DENIED: …') carrying no code field — the code token, not the sentence. Acceptable.
  • Six ablation legs reported, each restored (blob == HEAD, git diff HEAD empty): defineRule throw 5 red; the ledger filter in declaredRules 3; the ledger hook's reconcile call 3; the over-row report 2 (re-run after the logger fix in 768fc0b2c); the /data hook throw 2; the door's declaredRuleNamed lookup 3. Every pin family has a red leg. Not re-run here (read-only).

② Semver level

  • Changeset .changeset/24421-sharing-rule-declared-name-guard.md: @objectstack/plugin-sharing: minor, @objectstack/runtime: minor; summary fix(plugin-sharing,runtime)!:; Clause-②: no (narrowing); one ADR-0087 marker; **BREAKING** (an accept-set narrowing), shipped as minor under the launch-window convention; the refusals as inline FROM → TO pairs each with its one-line fix; the added surface; the unchanged set. The AGENTS.md §3 shape, and the launch-window level (check-changeset-no-major.mjs forbids major; the group returns to strict semver at GA). Check Changeset concluded success. @objectstack/rest is rightly no longer graded: its only change is a test file, which publishes nothing; the three packages are in one fixed group, so the group's level is minor either way.
  • FROM → TO completeness for the wider narrowing:
    • every same-name row now refused — POST /api/v1/sharing/rules and defineRule: FROM 201, the new row replacing the declared rule in its scope, TO 409 RESOURCE_CONFLICT naming it; the /data insert and the rename onto the name: FROM 201 / 200, TO 409. Fix on each: another name, plus the activation call. ✓
    • the customised-copy door removed — on main that door IS the same-name POST (the row that shadowed), so the first pair with its fix (clone under another name + switch the base off through POST /api/v1/security/_activation/sharing_rule/:name) is that retirement. Round 1's id-addressed door never shipped, so no pair is owed for it. ✓
    • same-name shadowing retired — stated ("Same-name shadowing is retired"), with the over-row paragraph for the rows that already exist ("a copy customised before this release … keeps answering as its own rule. The declaration is not served beside it, and that is logged at error"). Their wire behaviour is unchanged on this PR, so no pair is owed here; the migration of those copies is the ceremony stage's and its changeset carries that pair. ✓
    • DELETE on a switched-off declared rule: stated. ✓
  • minor is right; the ADR-0087 marker is right. not-required (no-migration-prescription) stands on the ADR's text: D8 — the ledger serves metadata upgraders, and the catch-all "cannot tell a prescription for a metadata upgrader from a prescription for a source-code consumer, and only the first is the ledger's business". Nothing here is a metadata rewrite: no packages/spec/** path in the file list, no *.zod.ts, no contracts entry, no object definition moved (sys-metadata-activation.object.ts unchanged; metadata_type is free text, so 'sharing_rule' is a new VALUE, not a shape); a stack's sharingRules are authored exactly as before, so ADR-0131 D13's "retirement of an authorable shape" does not reach. The pairs are an HTTP accept set with a request-level fix — what objectstack migrate meta cannot act on; registering would be the false ledger data the ADR's registered row warns against. The dev reworded a "FROM → TO" LABEL line into inline pairs after the gate read the label as a migration framing; the substance (pairs and fixes) is intact and the disposition is decided by the reading above, not by the detector's silence. check:adr-0087-registration (in Lint & Repo Gates) concluded success. The marker's why text is accurate.
  • Published surface — what widens, and whether it is declared. DeclaredSharingRule.packageId?: string (a type plugin-sharing's src/index.ts exports): optional and additive. SharingRuleService.declaredRuleNamed and .reconcileDeclaredRuleActivation: new public methods on the exported class. Both are minor widenings, subordinate to the narrowing and covered by the grade. SHARING_RULE_ACTIVATION_TYPE, DeclaredSharingRuleNameConflictError, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile and the two package-id constants are exported from the module but NOT from src/index.ts (unchanged) — not published; noted in ③. Runtime: SHARING_RULE_ACTIVATION_SUBJECT is not reachable from runtime's src/index.ts (no domains/* export); the door's accepted :type set widens by sharing_rule — a published HTTP widening, runtime: minor ✓. The spec contract (ISharingRuleService, DefineSharingRuleInput) is untouched — the amendment's ⛔ on packages/spec is honoured, and the ledger type (sys_metadata_activation) does not widen. One arm, no (narrowing), is right: the narrowing dominates.
  • The closing line. The amendment (item 5) binds Part of #24421; the ceremony stage carries Fixes #24421. The PR body says Fixes #24421 — wrong, and the title still names the dropped reconcile. The seat owes the body replacement (③); check-partof-closing-keyword RULE 1 and RULE 3 read a Part of #A body with no Fixes #A as clean (the dev reports exit 0 with the owed text), and the closing-target gate has nothing to check without a closing keyword. Both gates re-run on the edit.

③ Boundary flags

The round-2 report's four open_questions:

  1. A declaration over a stored row of its name → A, withhold in that scope and report; implemented. Serving both (B) would re-grant, between this PR and the ceremony stage, every vendor rule an administrator switched off through a pre-C customised copy — an over-share on main, and it would break the Audit sibling declared-metadata↔record two-store types (sys_position, sys_sharing_rule, sys_capability) per ADR-0094 addendum #2909 promise the D6 note says the migration and the ledger keep. A grants nothing new, is loud (error, once per name and scope per process, at the boot listing), and the ceremony stage removes the case. I read ruling item 2 and the D6 note's "never becomes an overlay" as A: the row gains no overlay semantics (no reconcile, no copy door), it is simply the organization's own rule with a reported collision. The seat records this reading on the card; a maintainer word is needed only if "reported loudly" was meant as a boot refusal rather than a log.

  2. An existing stored row holding a declared name stays editable → A. The ruling's guard predicate is "new or renamed"; freezing the row (B) would lock administrators out of their own switch-off before the migration exists. A rename AWAY passes the hook (pinned), which is the remedy the report names: the row becomes a clone.

  3. The door → A, the catalog door. One door, one gate, one store, one route-ledger row; the resolver reads the same table. A route under /sharing/rules would add a closed query set and a second gate for the same switch.

  4. declaredRuleNamed duck-typed, not on ISharingRuleService → A, a follow-up card. packages/spec was ⛔ this round, and the door fails closed without the reader (503, pinned). A consumer typed on the contract cannot discover "is this name declared" — the same contract-trails-enforcement gap round 1 flagged for the (now retired) id. Follow-up (Clause-②: yes, minor), to land before objectui#12982 builds on the SDK, or that card uses raw REST.

Stale PR body and title — every line now false, for the seat's replacement from the dev's owed pr_body_edits:

  • Title: "…and reconcile the shadowed declaration's grants at once" — the reconcile is dropped; the scope omits runtime.
  • Line 1 Fixes #24421 → Part of #24421.
  • Paragraph 1 entire: "Items 2–4 of ruling B′ …", "charted under regime O, with a guard", "A sys_sharing_rule row that holds a declared rule's name replaces that declaration in its scope", "the only way to create such a row is to edit the declared rule", "The write that creates, switches or removes the customised copy also reconciles the declaration's name-keyed grants in the same request" — false (only "A new rule or a rename that takes the name is refused" survives).
  • defineRule bullet: "…unless it is the edit of the projection the caller reads. An edit sends the projection's id, which is the rule's name (id: rule.name …)" — false.
  • Refusal bullet: "It says how to customise the rule: save it with its id. Otherwise, choose another name." — false (the message now says: another name, plus the activation door).
  • "The edit path stays open. Saving the declared rule back with its id stores the organization's customised copy … Further saves of the copy are the ordinary upsert by name and need no id." — false.
  • "The reconcile. The plugin's sys_sharing_rule write trigger now calls SharingRuleService.reconcileDeclaredRuleName …" and its three sub-bullets — false (removed).
  • "Scopes (the held key). … the declaration's grants are purged across every organization. Under a wall, an organization's copy affects only that organization's projection … When scopes are unknown, nothing is reconciled" — false as a description of a row-write reconcile; the switch is deployment-level and the only scope rule left is the over-row withholding.
  • Measurements: A1's "so the guard reads the projection's id" — false; A3 ("The reconcile reuses ruleGrantScope and evaluateResolvedRule … pinned with a reconcile failure injected") — false; A5's "The alternative, registered … the report names it as an open question" — stale (answered in round 1); A6 "523 changed lines (+516 / −7)" — now +782/−20 over 13 files.
  • Tests: "48 files / 1152 tests" → 1156; "@objectstack/rest … 9 files / 684 tests" → 3 files / 19 tests; "block regime O, guarded" → "regime C", 17 pins; the pins "The edit with id stores the copy", "The copy's creation revokes the declaration's grants", "An active change revokes a stale declared grant", "A reconcile failure skips the copy's pass", "Removing the copy re-derives the declaration's grants", "Walled: org_a's copy revokes only org_a's declared grants" — gone; REST "Preservation: the body's id reaches defineRule" — gone; the runtime door pins and the ledger pins are missing.
  • Ablations: "defineRule throw … 4 pins go red (the New refusals, the foreign-id refusal, the walled refusal)" → 5; "The trigger's reconcileDeclaredRuleName call replaced by void: 5 pins" — no such call; the three new legs (ledger filter, ledger hook, over-row report, door lookup) are missing.
  • Gates: "run locally at 81d447db8 … 100 families" → 768fc0b2c, 101 families.
  • Acceptance notes: "The id key is declared on the plugin's own DefineSharingRuleWrite …" — the type is gone; "The reconcile also handles the copy's removal …" — false; "A record write that lands between the trigger's rebind and its reconcile …" — false (no reconcile on the trigger; the analogous window is now the ledger write vs its after-hook, see below); the body says nothing of the ledger switch, the door, the over-row report, the runtime files or the runtime grade.
  • The owed replacement text in report 6259758546 is accurate on every point I checked against the diff, with one sentence the seat may sharpen: "A failure is thrown to the door, so it never sits behind a successful flip" is true of the response; the ledger row is written (next flag).

Further flags, none blocking:

  • Reconcile failure after the ledger write. The row flips, the door answers 5xx, the grants converge at the next boot sweep or the next flip (①(b)). A code-registered hook opens no abort unit (engine.ts:5082–5086), so there is no rollback. Same class as sharing: deactivating a rule never withdraws its materialized grants — not on touch, not at boot #4433, not widened; a pin on the boot sweep healing a switched-off rule's standing grants would close the gap in evidence. Follow-up, or the ceremony stage.
  • Fail-open ledger read. readSwitchedOffDeclaredRules treats a failed read as "all on" (as readPositionActive does), where core's readDisabledCatalogNames throws. Under a persistent ledger outage a switched-off rule grants again on the next boot pass. The plugin's stated posture against mass revocation; worth one sentence in the docs when sharing-rules.mdx is brought to regime C.
  • deleteRule under unknown scopes. main answered a silent no-op for a declared name there (declaredRules → []); the head answers 403 (declaredRuleNamed). A tightening in a degraded state the changeset does not spell out; a stored row holding the name is still found first (findRuleRow :1166–1187) and deletable, so nothing an organization owns is frozen.
  • Hand-written docs now one type behind or stale: content/docs/permissions/sharing-rules.mdx:220 ("POST {basePath}/sharing/rules with the same name" — still the upsert for a stored row, but 409 for a declared name, unsaid); content/docs/permissions/authorization.mdx:422 and content/docs/concepts/metadata-lifecycle.mdx:143 enumerate :type as permission or position only. Docs-only follow-up; release-owned pages untouched, correctly. The one doc edit in the diff (system-context.mdx row 40) is exact and required by check:system-context-census.
  • sys-metadata-activation.object.ts docblock names four readers; sharing rules are the fifth (D7 note at docs(adr): ADR-0131 D6 charts sharing_rule under regime C; D7 ledger reading and lines for #24377 and #24435; D14 records the 18.0 scope #24422). Doc-comment follow-up; no behaviour.
  • Surface. packages/runtime/src/{dispatcher-plugin,security,route-ledger}.ts are comment and ledger-note edits naming the new type, outside the amendment's named surface ("the activation door's file") but the door's own documentation rows; none is a governed surface (the register: docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/**), and Governed Surface Queue Guard concluded success. The amendment's "measure the existing door first" was done: the door is reused.
  • Unexported plugin symbols. DeclaredSharingRuleNameConflictError, SHARING_RULE_ACTIVATION_TYPE and the two bind functions are module-level but not on src/index.ts; consumers read the refusal by shape (code/status/rule), as the REST arm does. Export when a consumer needs them.
  • The card itself still carries the B′ title and body; the amendment comment governs, and the ceremony stage's Fixes #24421 closes it.
  • objectui#12982 (the report's out-of-scope finding): Disable calls POST /api/v1/security/_activation/sharing_rule/NAME; New surfaces the 409 (error.code === 'RESOURCE_CONFLICT'); round 1's "save back with id" guidance for that card is void. The seat carries.

Implemented-by: claude/issue-24421-sharing-rule-overlay-guard
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

This record was rendered by an isolated at-tier reviewer subagent of the adopting seat (epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3), on the inputs the brief named and nothing else, read-only; the seat adopts it. The verdict is on the committed tree at 768fc0b2c; the PR body and title are the seat's owed replacement before landing, and the two closing-line gates re-run on that edit.

VERDICT: PASS

@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-sharing)!: refuse a new or renamed sharing rule that takes a declared rule's name, and reconcile the shadowed declaration's grants at once plugin-sharing, runtime: sharing_rule under regime C, so every same-name row is refused and declared rules are switched through the activation ledger (ruling A on #24378) Oct 19, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 05:01
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 05:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 964a237 Oct 19, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24421-sharing-rule-overlay-guard branch October 19, 2026 05:27
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants