You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Filing gate ③: a task the maintainer directed. Decision #24378 was ruled B′: 「同意」 then 「B′」, recorded verbatim by the director seat at 6257388265. Reader who acts: the epic seat (epic:#15194) dispatches it to a cloud dev. Dedupe: the same search as the companion ADR card → no carrier.
What the ruling binds (verbatim items 2–4 of 6257388265)
The guard. Creating a sys_sharing_rule row, or renaming one, to the name of a declared rule in that scope through Setup's New (or any write that is not an edit of the projected declared row) is refused at the write door with the vendor rule named. Editing the projected row — the customised copy — stays the way to narrow or switch off a vendor rule, as today.
The reconcile. A same-name write (the customised copy's creation, or its active change) reconciles the shadowed declaration's name-keyed grants at once, not at the next boot's sweepOrphanedRuleGrants.
Under a wall an organisation's overlay is its own (today's holder key, sharing-rule-service.ts:887); under single the one overlay answers everywhere (today's behaviour).
Why
The ruling closes two consequences that stage 9 (PR #24376) measured. Both are recorded on #24378:
a same-name row created at runtime switches a vendor rule off at once;
the shadowed declaration's grants stand until the next restart.
Acceptance
The guard. A create or rename that takes a declared rule's name in its scope is refused, through the service and through POST/PATCH /api/v1/sharing/rules. The ADR-0112 refusal names the vendor rule. Pin the code and the HTTP status, not a message regex.
The edit path stays open. Editing the projected row still creates the customised copy, and narrows or switches off the vendor rule.
The reconcile. The customised copy's creation, and an active change on it, reconcile the declaration's grants in the same request. The pin observes the grants before the next boot.
Scopes. Under a wall the overlay is the organisation's own; under single it answers everywhere. Both are pinned.
Size and declaration. About 0.3–0.5k lines. Clause-② is measured by the dev: refusing a write that used to succeed is a narrowing.
Claim: PM loop round 5 · 2026-10-19T01:14Z
Session: session_01Y5583bs2RDd53MH6kM1ca3
Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-24421-sharing-rule-overlay-guard
Worktree: the cloud session's own checkout (mode:cloud)
Domain: domain:services
Seat: epic:#15194 (the epic lane; ruling B′ 6257388265 hands this stage to this seat)
File surface: packages/plugins/plugin-sharing/src/sharing-rule-service.ts (the write door and the reconcile), sharing-plugin.ts (only if the reconcile needs its wiring), packages/rest/src/sharing-routes.ts (only if the refusal needs its mapping), and their tests (stop on breach; explain in the report)
Container & model: M, mode:cloud, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate)
Clause-②: no (narrowing)
Responsibility: n/a — not a defect card
Thread-read: none
Serial constraints cleared:
os-dev-report
{
"issue": 24421,
"status": "done",
"branch": "claude/issue-24421-sharing-rule-overlay-guard",
"pr": "#24428",
"session": "session_01MaH9HpfmNnJGzgWQPqNmhP — this run's harness-stamped id (commit trailer Claude-Session line); dispatched by the epic seat session_01Y5583bs2RDd53MH6kM1ca3",
"premise_still_valid": true,
"summary": "Ruling B′ items 2–4 are implemented at both write doors. (1) The guard. SharingRuleService.defineRule (POST /api/v1/sharing/rules, SDK save, any service caller) refuses a NEW rule taking a declared rule's name. A new bindDeclaredRuleNameGuard beforeInsert/beforeUpdate hook on sys_sharing_rule (the /data door that Setup's New and edit form use) refuses an insert, or a rename onto a declared name, from a non-system writer. Both answer DeclaredSharingRuleNameConflictError: code RESOURCE_CONFLICT, status 409 (HttpStatusErrorCodeMap[409], catalogued, nothing minted). The message names the vendor rule and its object, and says to save the declared rule back with its id. (2) The edit path. A write carrying id equal to the declared name (the projection's id, as GET /sharing/rules lists it) stores the organization's customised copy as before. REST now plucks body.id through. (3) The reconcile. The plugin's sys_sharing_rule write trigger calls a new SharingRuleService.reconcileDeclaredRuleName for each name the write touched (the pre-image and the written row, each in its organization), BEFORE the row's own evaluateRule. Where a row holds the name, it purges the declaration's grants via ruleGrantScope(projectDeclaredRule(...)); where none does, it re-derives them via evaluateResolvedRule. A failure aborts before the copy's pass, so the two grant sets are never both in force. (4) Scopes follow the held key: under single the purge is everywhere; under a wall it covers only that organization's projection. A1 was partly falsified: there is no updateRule and no PATCH route, and Setup's Sharing Rules object list shows rows only (stage 9's migration entry), so a new signal (the projection's id) was the only way to tell the edit from the New.",
"tests": "At HEAD 81d447d. pnpm --filter @objectstack/plugin-sharing test: 'Test Files 48 passed (48) / Tests 1152 passed (1152)', VERDICT command-exit 0. pnpm --filter @objectstack/plugin-sharing typecheck: exit 0 (tsc + scripts + check:test-typecheck OK, debt unchanged at 3 pinned signatures). @objectstack/rest exec vitest run on the 7 sharing test files plus rest-write-route-hook-refusal-sentence.ledger and rest-write-response-internal-fields.tripwire: 'Test Files 9 passed (9) / Tests 684 passed (684)'. pnpm --filter @objectstack/rest typecheck: exit 0. New pins: 13 in sharing-rule.test.ts ('regime O, guarded' block, on the file's existing pinned engine double; code+status asserted, never the message), and 3 in the new packages/rest/src/sharing-rule-declared-name-conflict.test.ts (409 nested envelope; preservation: body.id reaches defineRule; anti-vacuity 500). Ablations via scripts/ablation-replace.mjs, each restored with blob == HEAD and git diff HEAD empty. The plugin tests import src relatively, so no dist leg applies. defineRule throw replaced by void 0: 4 failed / 9 passed. Trigger reconcile call replaced by void: 5 failed / 8 passed. /data hook throw replaced by void: 2 failed / 11 passed.",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, after the final commit 81d447d): 100 families, plus the STDERR artifact-roster block, 149 commands in all. Each exit code was captured to a file before any pipe. 145 exited 0. Four exited non-zero for missing PR or event context, which is NOT MEASURED locally and read on CI: check-closing-target-claim (2), check-single-claim-paths (2), check-partof-closing-keyword (2; re-run with PR_BODY set to the PR body: exit 0) and pm/check-governed-queue-guard (1, no GITHUB_EVENT_PATH). The --ran reconciliation reads: 'Run reconciliation — 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero with every line exit-coded. check:dual-build-cjs-loads, check:i18n and check:published-readme-exports first answered exit 3 (no dist); they passed after a full turbo run build (VERDICT command-exit 0, 8m08s). check:system-context-census first went red on the guard's isSystem read (site-without-a-row); it passed after row 40 of content/docs/permissions/system-context.mdx was given the #bindDeclaredRuleNameGuard anchor. check:adr-0087-registration accepts 'not-required (no-migration-prescription)'. The first gate run overlapped the three ablation windows; the counted run is the second one, on the final head. CI on PR #24428 at report time: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke' skipped, every other check in_progress (Lint lanes, Type Check lanes, Check Changeset, Governed Surface Queue Guard, single-claim, Part-of, closing-target).",
"mcp_calls": "2 — mcp__github__issue_read (get, get_comments) on #24421; both reads, no write tool. Other reads used gh api GETs.",
"api_writes": "2 relay strokes as objectstack-fleet[bot] via scripts/pm/with-fleet.sh --via dispatch: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24428/assignees [os-steve]; (2) comment = POST /repos//issues/24421/comments (this report). git push is not a REST write.",
"open_questions": [
{
"question": "ADR-0087 disposition. The changeset holds 'not-required (no-migration-prescription)' and the gate accepts it. But its body carries a FROM → TO line and a fix (send the declared rule's id), as the brief asked. The honest alternative is 'registered' with a new semantic migration entry (for example sharing-rule-declared-name-guarded) under packages/spec/src/migrations/entries/semantic/, as stage 9 did. That path is off this card's file surface.",
"options": ["A keep not-required (no-migration-prescription): no spec key, export, option or stored metadata shape moves, so objectstack migrate meta has nothing to rewrite; the CHANGELOG carries the fix", "B register a semantic ADR-0087 entry (about 40 lines in packages/spec, plus the registry index) in this PR or a follow-up, with the seat authorizing the spec surface"],
"recommendation": "B, if the seat will open packages/spec for it. Two stages of this epic failed review on an undeclared narrowing; the gate accepting A rests on its prescription detector not reading this body's fix line, which is the gap ADR-0087 warns against relying on. A is defensible on the letter (no metadata moves) and costs nothing now."
},
{
"question": "The edit signal 'id' is declared only on the plugin's DefineSharingRuleWrite (DefineSharingRuleInput & { id?: string }). The spec contract DefineSharingRuleInput (packages/spec/src/contracts/sharing-service.ts) and the SDK's shares.rules.save parameter type do not declare it. Both are off-surface.",
"options": ["A follow-up card: add optional id to DefineSharingRuleInput and to the client save type, documented as 'the id of the rule this write edits'", "B leave it plugin-local"],
"recommendation": "A, contract-first: an AI writing against the spec contract cannot discover the only door that customises a vendor rule; the 409 message names it, but declaration should not trail enforcement."
},
{
"question": "The reconcile also runs when the customised copy is DELETED, re-deriving the declaration's grants at once. That is the same name-and-scope path. The ruling names only the copy's creation and its active change.",
"options": ["A keep it: removing the overlay restores the vendor rule's access in the same request instead of at the next boot backfill", "B drop it and leave the delete path to the boot backfill"],
"recommendation": "A: the code path is shared and costs nothing extra, and it closes the mirror-image under-grant window."
},
{
"question": "Setup has no door that edits a projection. Its Sharing Rules nav item is a plain sys_sharing_rule object list, which per stage 9 shows rows only. So the ruling's 'edit the projected row in Setup' exists today only through GET+POST /api/v1/sharing/rules with id. Whether objectui has a page on /sharing/rules is NOT MEASURED: the sibling repo is outside this session.",
"options": ["A the epic seat checks objectui and, if needed, files a UI card to list declared rules and save them back with their id", "B accept REST/SDK-only customisation for now"],
"recommendation": "A: without a UI edit door an admin who hits the 409 in Setup has no Setup remedy; the message names the REST one."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — content/docs/permissions/sharing-rules.mdx (hand-written) does not describe the declared-name guard or the id-addressed edit; off this card's file surface; recorded in the PR's Acceptance notes",
"carrier: epic seat (epic:#15194) · noted, not filed — Setup's Sharing Rules object list has no edit door for a declared rule's projection (see open question 4); objectui not measured"
]
}
Ruling-pointer · #24378 re-ruled A at 6258883445 (batch #368 item 1, the maintainer's 「24378 同意 A」; supersedes B′ 6257388265, under which this card was cut) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-19T03:18Z
This card's premise changed: sharing_rule is under regime C, not O. PR #24428 is reworked, not landed as built — the guard stays and widens to refuse every sys_sharing_rule row that takes a declared rule's name in its scope (the edit-the-projection path included: customising is now a clone under a new name); the switch-off becomes a sys_metadata_activation row of type sharing_rule (deployment-level, operator-gated under a wall); the immediate reconcile is dropped; the ceremony migrates existing customised copies to clone + base-off. The epic seat amends the claim; the dev reads ruling 6258883445 on #24378 before touching the branch.
Claim amendment · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T03:25Z. It amends claim 6257564383. The branch and session are unchanged: claude/issue-24421-sharing-rule-overlay-guard, session_01MaH9HpfmNnJGzgWQPqNmhP.
Why. Ruling A on #24378 (6258883445; the maintainer's 「24378 同意 A」) supersedes B′. sharing_rule moves to regime C: read-only, clonable, and switched through the activation ledger. PR #24428 is reworked under item 5 of that ruling.
A sys_sharing_rule row that takes a declared rule's name in its scope is refused, whether it is new or renamed. This applies through the service, POST /api/v1/sharing/rules and the /data door.
The id-addressed edit of a projection is removed: there is no customised-copy door any more.
Customising means a clone under a new name, plus the base switched off.
The ledger switch.
sys_metadata_activation gains the sharing_rule type. A declared rule whose row says active: false is not served (declaredRules), and its name-keyed grants are purged when the switch flips.
The switch is deployment-level and operator-gated under a wall (ADR-0131 D6, as for flows and actions). It is read the way the resolver already reads positions and permission sets.
Use the existing activation door for flows and actions if it fits. Measure it first.
A declaration arriving over an authored row (a package upgrade) is reported loudly at load and never becomes an overlay.
The immediate reconcile is dropped. Nothing shadows a declaration any more.
Closing line:Part of #24421, not Fixes. The ceremony step below closes the card.
Not in this PR: the ceremony step (ruling item 3). It migrates each existing customised copy to clone + base-off and deletes the uncustomised mirrors (D10 fate 2). It edits the ownership ceremony files, which 8b-0de will hold next, so the seat dispatches it as its own stage once this PR merges and the ceremony files are free. That stage carries Fixes #24421.
File surface:
the claim's surface (packages/plugins/plugin-sharing/**, packages/rest/src/sharing-routes.ts, their tests);
the activation door's file, if it needs the type admitted;
packages/platform-objects/src/system/sys-metadata-activation.object.ts, only if its type text must name sharing_rule.
⛔ Not the ownership ceremony files. ⛔ Not packages/spec/** without a stop-and-report.
Clause-②: to be measured by the dev. Refusing every same-name row and retiring the customised-copy door are narrowings.
os-dev-report
{
"issue": 24421,
"status": "done",
"branch": "claude/issue-24421-sharing-rule-overlay-guard",
"pr": "#24428",
"session": "session_01MaH9HpfmNnJGzgWQPqNmhP — this run's harness-stamped id; rework round dispatched by the epic seat session_01Y5583bs2RDd53MH6kM1ca3 (cross-session message), verified against ruling A 6258883445 and claim amendment 6258985416 before acting",
"premise_still_valid": true,
"summary": "Rework round for ruling A (regime C), pushed to the same branch; the head is now 768fc0b. (1) The guard is widened. defineRule refuses EVERY new row under a declared name, the id-addressed projection edit is removed, and a switched-off declared rule still holds its name. The /data hook (insert, or a rename onto the name) is unchanged. Both answer 409 RESOURCE_CONFLICT. (2) The ledger switch. sys_metadata_activation metadata_type 'sharing_rule' (SHARING_RULE_ACTIVATION_TYPE). declaredRules filters out ledger-off declarations; a failed read switches nothing off, as positions do. A new hook, bindSharingRuleActivationReconcile, on afterInsert/afterUpdate/afterDelete of the ledger, calls reconcileDeclaredRuleActivation in the same request: off purges every grant carrying the name, deployment-wide; on re-derives the grants per projection; unknown scopes do nothing. The door is the existing catalog activation door, POST /api/v1/security/_activation/:type/:name, with sharing_rule admitted (runtime catalog-activation.ts + activation-gate.ts subject). It resolves the name via the sharingRules service's declaredRuleNamed and keeps the same manage_metadata + operator-under-a-wall gate; the row records packageId. (3) A declaration over a stored row is withheld in that scope (fail-closed) and logged at error once per name and scope per process, with the remedy; the row answers as itself. (4) The immediate reconcile on sys_sharing_rule writes is dropped, its delete path included: sharing-plugin.ts's trigger and sharing-routes.ts are back to main. (5) deleteRule answers 403 for a declared rule even when it is switched off.",
"tests": "At head 768fc0b. pnpm --filter @objectstack/plugin-sharing test: 'Test Files 48 passed (48) / Tests 1156 passed (1156)', VERDICT command-exit 0; typecheck exit 0. @objectstack/runtime: vitest on the 7 activation-door test files, 'Test Files 7 passed (7) / Tests 145 passed (145)'; typecheck exit 0. @objectstack/rest: sharing-rule-declared-name-conflict, sharing-rule-evaluate-inert-object and sharing-envelope, 'Tests 19 passed'; typecheck exit 0. New pins: 17 in sharing-rule.test.ts (block 'regime C', on the file's existing pinned engine double) and 6 in catalog-activation-door.test.ts (the file's existing double, with an extraServices hook on boot). The REST file is trimmed to the 409 mapping plus anti-vacuity, because the id preservation pin went with the id pluck. Ablations via scripts/ablation-replace.mjs, every leg restored with blob == HEAD and git diff HEAD empty (src imported relatively, so no dist leg): defineRule throw: 5 red. The ledger filter in declaredRules: 3 red. The ledger hook's reconcile call: 3 red. The declaration-over-row report: 2 red (re-run after the logger fix). The /data hook throw: 2 red. The door's declaredRuleNamed lookup: 3 red.",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, on the final head): 101 families, plus the STDERR artifact-roster block, 149 commands in all. Each exit code was captured to a file before any pipe. The full turbo build ran first (VERDICT command-exit 0). The first pass at 75bcef5 reddened check:logger-receiver-detach on the new report's error/warn fallback; fixed in 768fc0b and re-run. The final pass at 768fc0b: 145 exited 0. The other four need PR or event context and are NOT MEASURED locally: check-closing-target-claim (2), check-single-claim-paths (2), check-partof-closing-keyword (2; exit 0 with PR_BODY set to the owed body below) and pm/check-governed-queue-guard (1). Two runner instances overlapped for indices 127–149 of the final pass; the per-index LAST record, all from the final-head runner, is what counts. --ran reads: 'Run reconciliation — 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero. check:adr-0087-registration accepts not-required (no-migration-prescription), after a 'FROM → TO' label line was reworded to inline pairs; the gate reads a label as a migration framing. CI at report time on 768fc0b: 33 success, 5 skipped, 2 in_progress, 0 failure.",
"mcp_calls": "0 this round — reads went through gh api GETs.",
"api_writes": "1 relay stroke this round: comment = POST /repos//issues/24421/comments (this report). No PR-body write (the seat writes it). git push is not a REST write.",
"pr_body_edits": "Owed: replace the PR #24428 body whole with the text below. Line 1 is now 'Part of #24421'; check-partof-closing-keyword with this text as PR_BODY exits 0, and check-closing-keyword-parity exits 0.\n\nPart of #24421\nClause-②: no (narrowing)\n\nThis 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.\n\n## What changed\n\nThe guard: every same-name row is refused with 409 RESOURCE_CONFLICT, naming the declared rule. It applies at both write doors.\n- 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.\n- 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.\n- DeclaredSharingRuleNameConflictError carries codeRESOURCE_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.\n\nThe ledger switch.sys_metadata_activation now records metadata_typesharing_rule (SHARING_RULE_ACTIVATION_TYPE).\n- 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.\n- 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, so it never sits behind a successful flip.\n- 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.\n\nA 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).\n\nDropped: 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.\n\n## Tests\n\n- pnpm --filter @objectstack/plugin-sharing test: 48 files / 1156 tests. typecheck: exit 0.\n- @objectstack/runtime: the 7 activation-door test files, 145 tests. typecheck: exit 0.\n- @objectstack/rest: the sharing-rule test files, 19 tests. typecheck: exit 0.\n- sharing-rule.test.ts, block "regime C", 17 pins:\n - 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.\n - 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.\n - Another type's ledger row does nothing. A failed ledger read revokes nothing. A switched-off rule cannot be deleted (403).\n - A declaration over a stored row is withheld and reported once, and the row stays editable. Walled, it is reported per organization.\n - The /data door refuses an insert and a rename onto the name.\n - Walled, one ledger row switches the rule off in every organization and back on. Unknown scopes reconcile nothing.\n- 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.\n- sharing-rule-declared-name-conflict.test.ts: POST /sharing/rules answers 409 nested.\n- Ablations (scripts/ablation-replace.mjs; each leg restored, blob == HEAD, git diff HEAD empty; the tests import src/ relatively):\n\n | Ablated | Red |\n |---|---|\n | defineRule throw | 5 |\n | the ledger filter in declaredRules | 3 |\n | the ledger hook's reconcile call | 3 |\n | the declaration-over-row report | 2 |\n | the /data hook throw | 2 |\n | the door's declaredRuleNamed lookup | 3 |\n\nGates 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.\n\n## Acceptance notes\n\n- 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.\n- Files outside the plugin, all within the amended surface or doc-comment only:\n - runtime/src/domains/{catalog-activation,activation-gate}.ts: the activation door.\n - runtime/src/domains/security.ts, dispatcher-plugin.ts, route-ledger.ts: notes now name sharing_rule.\n - content/docs/permissions/system-context.mdx row 40: the guard's isSystem anchor.\n- sys-metadata-activation.object.ts is unchanged. Its metadata_type description reads "('flow', 'permission', …)" and needed no edit.\n- Not in this PR:\n - the ceremony step, the stage that finishes the card;\n - objectui#12982 (Disable and Clone);\n - content/docs/permissions/sharing-rules.mdx, which does not describe regime C yet.\n\n---\n_Generated by Claude Code\n",
"open_questions": [
{
"question": "A declaration arriving over a stored row of its name: the ruling says 'reported loudly at load, never made an overlay'. Two readings: serve BOTH the declaration and the row, or withhold the declaration in that scope and report it.",
"options": [
"A withhold the declaration in that scope (fail-closed) and log at error once per name and scope: the row answers as itself, and no pre-C customised copy's switch-off is undone before the ceremony stage migrates it",
"B serve both: the row as its own rule and the declaration beside it, logged"
],
"recommendation": "A, implemented. B re-grants every vendor rule an admin switched off through a pre-C customised copy, between this PR and the ceremony stage: an over-share on main. A grants nothing new, is loud, and the ceremony stage then removes the case. Seat to confirm A is the ruling's intent."
},
{
"question": "An existing stored row that already holds a declared name (a pre-C customised copy) stays editable through defineRule's upsert and the /data door. Neither is a NEW row or a rename onto the name, so the guard does not apply.",
"options": [
"A keep it editable until the ceremony stage migrates it",
"B refuse any write to such a row"
],
"recommendation": "A: B would freeze admins out of their own switch-off before the migration exists."
},
{
"question": "The activation door. The ruling says 'read the way the resolver reads positions and permission sets'. The existing door for those, the security catalog's POST /security/_activation/:type/:name, was extended with sharing_rule, resolving the name through the sharingRules service and not the security catalog reader.",
"options": [
"A keep it on the catalog door (one door, one gate, one store)",
"B a separate route under /sharing/rules"
],
"recommendation": "A: B adds a route, a closed query set and a ledger row for the same switch."
},
{
"question": "declaredRuleNamed (the reader the door uses) is a SharingRuleService method, not on ISharingRuleService in packages/spec/src/contracts. The door reads it duck-typed and answers 503 when it is absent.",
"options": [
"A a follow-up card declares declaredRuleNamed (or an equivalent reader) on the spec contract",
"B leave it duck-typed"
],
"recommendation": "A, contract-first; packages/spec is off this round's surface."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — content/docs/permissions/sharing-rules.mdx (hand-written) does not describe regime C (the name guard, Disable through the activation door, clone-to-customise); recorded in the owed PR body's Acceptance notes",
"carrier: epic seat (epic:#15194) · noted, not filed — objectui#12982 now needs Disable to call POST /api/v1/security/_activation/sharing_rule/NAME and New to surface the 409; the first round's 'save back with id' guidance for that card is void"
]
}
Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T05:44Z. PR #24428 (Part of #24421) has merged: sharing_rule is now under regime C, per ruling A on #24378 (6258883445).
Review. Round 2 PASSED at CONTRACT_REVIEW_TIER on the narrowing face (record 6260026585 at 768fc0b2c). Round 1 had passed under the superseded B′ (6258643741).
merged: true at 2026-10-19T05:27:43Z; merge commit 964a2370
State change: none. The card stays open. Its closing stage is ruling A item 3, the ceremony's migration of customised copies (stage 9c). That stage moved to 18.x with the upgrade path (ruling record 6259608309 on #15194).
Delivered:
Same-name rows are refused. Every row that takes a declared rule's name, new or renamed, is refused with 409 RESOURCE_CONFLICT. This holds at the service, at POST /api/v1/sharing/rules and at the /data door. The round-1 id door is gone.
Declared rules can be switched through the ledger.POST /api/v1/security/_activation/sharing_rule/:name writes a sys_metadata_activation row of type sharing_rule. The switch is deployment-level and operator-gated under a wall. Its name-keyed grants are purged and re-derived in the same request.
A declaration that arrives over an authored row is withheld in that scope and reported at error, once per name and scope.
Changeset:minor, with FROM→TO pairs, under the launch-window rule.
Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim; the assignee is unchanged.
The maintainer's rule: 「发版本优先的都p1」. The maintainer asked in chat whether every 18.0 card is high priority, and this card was one of the three target:v18 cards still at p2.
Epic seat (epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3) · 2026-10-19T12:07Z. State change: pm:dispatched → pm:blocked. Nothing is in flight on this card. Its 18.0 part has landed, and its remaining stages are on the 18.x line.
Under ruling record 6266750393 on #15194, the 18.x line is serial and keeps main releasable after every merge. Step 0 (#24522) measures and builds the 18.0→HEAD upgrade harness. Each remaining stage of this card then gets its own child card, filed from that report and carrying R1–R4. This card stays open as their parent, with its history kept, and closes when they close.
Activity
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 5 · 2026-10-19T01:14Z
Session:
session_01Y5583bs2RDd53MH6kM1ca3Account:
os-steve(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-24421-sharing-rule-overlay-guardWorktree: the cloud session's own checkout (
mode:cloud)Domain:
domain:servicesSeat:
epic:#15194(the epic lane; ruling B′ 6257388265 hands this stage to this seat)File surface:
packages/plugins/plugin-sharing/src/sharing-rule-service.ts(the write door and the reconcile),sharing-plugin.ts(only if the reconcile needs its wiring),packages/rest/src/sharing-routes.ts(only if the refusal needs its mapping), and their tests (stop on breach; explain in the report)Container & model:
M,mode:cloud,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate)Clause-②: no (narrowing)
Responsibility: n/a — not a defect card
Thread-read: none
Serial constraints cleared:
5e979073.origin/main71fa166e).objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24421,
"status": "done",
"branch": "claude/issue-24421-sharing-rule-overlay-guard",
"pr": "#24428",
"session": "session_01MaH9HpfmNnJGzgWQPqNmhP — this run's harness-stamped id (commit trailer Claude-Session line); dispatched by the epic seat session_01Y5583bs2RDd53MH6kM1ca3",
"premise_still_valid": true,
"summary": "Ruling B′ items 2–4 are implemented at both write doors. (1) The guard. SharingRuleService.defineRule (POST /api/v1/sharing/rules, SDK save, any service caller) refuses a NEW rule taking a declared rule's name. A new bindDeclaredRuleNameGuard beforeInsert/beforeUpdate hook on sys_sharing_rule (the /data door that Setup's New and edit form use) refuses an insert, or a rename onto a declared name, from a non-system writer. Both answer DeclaredSharingRuleNameConflictError: code RESOURCE_CONFLICT, status 409 (HttpStatusErrorCodeMap[409], catalogued, nothing minted). The message names the vendor rule and its object, and says to save the declared rule back with its id. (2) The edit path. A write carrying id equal to the declared name (the projection's id, as GET /sharing/rules lists it) stores the organization's customised copy as before. REST now plucks body.id through. (3) The reconcile. The plugin's sys_sharing_rule write trigger calls a new SharingRuleService.reconcileDeclaredRuleName for each name the write touched (the pre-image and the written row, each in its organization), BEFORE the row's own evaluateRule. Where a row holds the name, it purges the declaration's grants via ruleGrantScope(projectDeclaredRule(...)); where none does, it re-derives them via evaluateResolvedRule. A failure aborts before the copy's pass, so the two grant sets are never both in force. (4) Scopes follow the held key: under single the purge is everywhere; under a wall it covers only that organization's projection. A1 was partly falsified: there is no updateRule and no PATCH route, and Setup's Sharing Rules object list shows rows only (stage 9's migration entry), so a new signal (the projection's id) was the only way to tell the edit from the New.",
"tests": "At HEAD 81d447d. pnpm --filter @objectstack/plugin-sharing test: 'Test Files 48 passed (48) / Tests 1152 passed (1152)', VERDICT command-exit 0. pnpm --filter @objectstack/plugin-sharing typecheck: exit 0 (tsc + scripts + check:test-typecheck OK, debt unchanged at 3 pinned signatures). @objectstack/rest exec vitest run on the 7 sharing test files plus rest-write-route-hook-refusal-sentence.ledger and rest-write-response-internal-fields.tripwire: 'Test Files 9 passed (9) / Tests 684 passed (684)'. pnpm --filter @objectstack/rest typecheck: exit 0. New pins: 13 in sharing-rule.test.ts ('regime O, guarded' block, on the file's existing pinned engine double; code+status asserted, never the message), and 3 in the new packages/rest/src/sharing-rule-declared-name-conflict.test.ts (409 nested envelope; preservation: body.id reaches defineRule; anti-vacuity 500). Ablations via scripts/ablation-replace.mjs, each restored with blob == HEAD and git diff HEAD empty. The plugin tests import src relatively, so no dist leg applies. defineRule throw replaced by void 0: 4 failed / 9 passed. Trigger reconcile call replaced by void: 5 failed / 8 passed. /data hook throw replaced by void: 2 failed / 11 passed.",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, after the final commit 81d447d): 100 families, plus the STDERR artifact-roster block, 149 commands in all. Each exit code was captured to a file before any pipe. 145 exited 0. Four exited non-zero for missing PR or event context, which is NOT MEASURED locally and read on CI: check-closing-target-claim (2), check-single-claim-paths (2), check-partof-closing-keyword (2; re-run with PR_BODY set to the PR body: exit 0) and pm/check-governed-queue-guard (1, no GITHUB_EVENT_PATH). The --ran reconciliation reads: 'Run reconciliation — 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero with every line exit-coded. check:dual-build-cjs-loads, check:i18n and check:published-readme-exports first answered exit 3 (no dist); they passed after a full turbo run build (VERDICT command-exit 0, 8m08s). check:system-context-census first went red on the guard's isSystem read (site-without-a-row); it passed after row 40 of content/docs/permissions/system-context.mdx was given the #bindDeclaredRuleNameGuard anchor. check:adr-0087-registration accepts 'not-required (no-migration-prescription)'. The first gate run overlapped the three ablation windows; the counted run is the second one, on the final head. CI on PR #24428 at report time: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke' skipped, every other check in_progress (Lint lanes, Type Check lanes, Check Changeset, Governed Surface Queue Guard, single-claim, Part-of, closing-target).",
"mcp_calls": "2 — mcp__github__issue_read (get, get_comments) on #24421; both reads, no write tool. Other reads used gh api GETs.",
"api_writes": "2 relay strokes as objectstack-fleet[bot] via scripts/pm/with-fleet.sh --via dispatch: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24428/assignees [os-steve]; (2) comment = POST /repos//issues/24421/comments (this report). git push is not a REST write.",
"open_questions": [
{
"question": "ADR-0087 disposition. The changeset holds 'not-required (no-migration-prescription)' and the gate accepts it. But its body carries a FROM → TO line and a fix (send the declared rule's id), as the brief asked. The honest alternative is 'registered' with a new semantic migration entry (for example sharing-rule-declared-name-guarded) under packages/spec/src/migrations/entries/semantic/, as stage 9 did. That path is off this card's file surface.",
"options": ["A keep not-required (no-migration-prescription): no spec key, export, option or stored metadata shape moves, so objectstack migrate meta has nothing to rewrite; the CHANGELOG carries the fix", "B register a semantic ADR-0087 entry (about 40 lines in packages/spec, plus the registry index) in this PR or a follow-up, with the seat authorizing the spec surface"],
"recommendation": "B, if the seat will open packages/spec for it. Two stages of this epic failed review on an undeclared narrowing; the gate accepting A rests on its prescription detector not reading this body's fix line, which is the gap ADR-0087 warns against relying on. A is defensible on the letter (no metadata moves) and costs nothing now."
},
{
"question": "The edit signal 'id' is declared only on the plugin's DefineSharingRuleWrite (DefineSharingRuleInput & { id?: string }). The spec contract DefineSharingRuleInput (packages/spec/src/contracts/sharing-service.ts) and the SDK's shares.rules.save parameter type do not declare it. Both are off-surface.",
"options": ["A follow-up card: add optional id to DefineSharingRuleInput and to the client save type, documented as 'the id of the rule this write edits'", "B leave it plugin-local"],
"recommendation": "A, contract-first: an AI writing against the spec contract cannot discover the only door that customises a vendor rule; the 409 message names it, but declaration should not trail enforcement."
},
{
"question": "The reconcile also runs when the customised copy is DELETED, re-deriving the declaration's grants at once. That is the same name-and-scope path. The ruling names only the copy's creation and its active change.",
"options": ["A keep it: removing the overlay restores the vendor rule's access in the same request instead of at the next boot backfill", "B drop it and leave the delete path to the boot backfill"],
"recommendation": "A: the code path is shared and costs nothing extra, and it closes the mirror-image under-grant window."
},
{
"question": "Setup has no door that edits a projection. Its Sharing Rules nav item is a plain sys_sharing_rule object list, which per stage 9 shows rows only. So the ruling's 'edit the projected row in Setup' exists today only through GET+POST /api/v1/sharing/rules with id. Whether objectui has a page on /sharing/rules is NOT MEASURED: the sibling repo is outside this session.",
"options": ["A the epic seat checks objectui and, if needed, files a UI card to list declared rules and save them back with their id", "B accept REST/SDK-only customisation for now"],
"recommendation": "A: without a UI edit door an admin who hits the 409 in Setup has no Setup remedy; the message names the REST one."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — content/docs/permissions/sharing-rules.mdx (hand-written) does not describe the declared-name guard or the id-addressed edit; off this card's file surface; recorded in the PR's Acceptance notes",
"carrier: epic seat (epic:#15194) · noted, not filed — Setup's Sharing Rules object list has no edit door for a declared rule's projection (see open question 4); objectui not measured"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Ruling-pointer · #24378 re-ruled A at 6258883445 (batch #368 item 1, the maintainer's 「24378 同意 A」; supersedes B′ 6257388265, under which this card was cut) · director seat (summon #43 continuation 5,
session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-19T03:18ZThis card's premise changed:
sharing_ruleis under regime C, not O. PR #24428 is reworked, not landed as built — the guard stays and widens to refuse everysys_sharing_rulerow that takes a declared rule's name in its scope (the edit-the-projection path included: customising is now a clone under a new name); the switch-off becomes asys_metadata_activationrow of typesharing_rule(deployment-level, operator-gated under a wall); the immediate reconcile is dropped; the ceremony migrates existing customised copies to clone + base-off. The epic seat amends the claim; the dev reads ruling 6258883445 on #24378 before touching the branch.objectstack-fleet commentedon Oct 19, 2026
Claim amendment · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T03:25Z. It amends claim 6257564383. The branch and session are unchanged:claude/issue-24421-sharing-rule-overlay-guard,session_01MaH9HpfmNnJGzgWQPqNmhP.Why. Ruling A on #24378 (6258883445; the maintainer's 「24378 同意 A」) supersedes B′.
sharing_rulemoves to regime C: read-only, clonable, and switched through the activation ledger. PR #24428 is reworked under item 5 of that ruling.The rework (PR #24428):
sys_sharing_rulerow that takes a declared rule's name in its scope is refused, whether it is new or renamed. This applies through the service,POST /api/v1/sharing/rulesand the/datadoor.id-addressed edit of a projection is removed: there is no customised-copy door any more.sys_metadata_activationgains thesharing_ruletype. A declared rule whose row saysactive: falseis not served (declaredRules), and its name-keyed grants are purged when the switch flips.Part of #24421, notFixes. The ceremony step below closes the card.Not in this PR: the ceremony step (ruling item 3). It migrates each existing customised copy to clone + base-off and deletes the uncustomised mirrors (D10 fate 2). It edits the ownership ceremony files, which 8b-0de will hold next, so the seat dispatches it as its own stage once this PR merges and the ceremony files are free. That stage carries
Fixes #24421.File surface:
packages/plugins/plugin-sharing/**,packages/rest/src/sharing-routes.ts, their tests);packages/platform-objects/src/system/sys-metadata-activation.object.ts, only if its type text must namesharing_rule.⛔ Not the ownership ceremony files. ⛔ Not
packages/spec/**without a stop-and-report.Clause-②: to be measured by the dev. Refusing every same-name row and retiring the customised-copy door are narrowings.
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24421,
"status": "done",
"branch": "claude/issue-24421-sharing-rule-overlay-guard",
"pr": "#24428",
"session": "session_01MaH9HpfmNnJGzgWQPqNmhP — this run's harness-stamped id; rework round dispatched by the epic seat session_01Y5583bs2RDd53MH6kM1ca3 (cross-session message), verified against ruling A 6258883445 and claim amendment 6258985416 before acting",
"premise_still_valid": true,
"summary": "Rework round for ruling A (regime C), pushed to the same branch; the head is now 768fc0b. (1) The guard is widened. defineRule refuses EVERY new row under a declared name, the id-addressed projection edit is removed, and a switched-off declared rule still holds its name. The /data hook (insert, or a rename onto the name) is unchanged. Both answer 409 RESOURCE_CONFLICT. (2) The ledger switch. sys_metadata_activation metadata_type 'sharing_rule' (SHARING_RULE_ACTIVATION_TYPE). declaredRules filters out ledger-off declarations; a failed read switches nothing off, as positions do. A new hook, bindSharingRuleActivationReconcile, on afterInsert/afterUpdate/afterDelete of the ledger, calls reconcileDeclaredRuleActivation in the same request: off purges every grant carrying the name, deployment-wide; on re-derives the grants per projection; unknown scopes do nothing. The door is the existing catalog activation door, POST /api/v1/security/_activation/:type/:name, with sharing_rule admitted (runtime catalog-activation.ts + activation-gate.ts subject). It resolves the name via the sharingRules service's declaredRuleNamed and keeps the same manage_metadata + operator-under-a-wall gate; the row records packageId. (3) A declaration over a stored row is withheld in that scope (fail-closed) and logged at error once per name and scope per process, with the remedy; the row answers as itself. (4) The immediate reconcile on sys_sharing_rule writes is dropped, its delete path included: sharing-plugin.ts's trigger and sharing-routes.ts are back to main. (5) deleteRule answers 403 for a declared rule even when it is switched off.",
"tests": "At head 768fc0b. pnpm --filter @objectstack/plugin-sharing test: 'Test Files 48 passed (48) / Tests 1156 passed (1156)', VERDICT command-exit 0; typecheck exit 0. @objectstack/runtime: vitest on the 7 activation-door test files, 'Test Files 7 passed (7) / Tests 145 passed (145)'; typecheck exit 0. @objectstack/rest: sharing-rule-declared-name-conflict, sharing-rule-evaluate-inert-object and sharing-envelope, 'Tests 19 passed'; typecheck exit 0. New pins: 17 in sharing-rule.test.ts (block 'regime C', on the file's existing pinned engine double) and 6 in catalog-activation-door.test.ts (the file's existing double, with an extraServices hook on boot). The REST file is trimmed to the 409 mapping plus anti-vacuity, because the id preservation pin went with the id pluck. Ablations via scripts/ablation-replace.mjs, every leg restored with blob == HEAD and git diff HEAD empty (src imported relatively, so no dist leg): defineRule throw: 5 red. The ledger filter in declaredRules: 3 red. The ledger hook's reconcile call: 3 red. The declaration-over-row report: 2 red (re-run after the logger fix). The /data hook throw: 2 red. The door's declaredRuleNamed lookup: 3 red.",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, on the final head): 101 families, plus the STDERR artifact-roster block, 149 commands in all. Each exit code was captured to a file before any pipe. The full turbo build ran first (VERDICT command-exit 0). The first pass at 75bcef5 reddened check:logger-receiver-detach on the new report's error/warn fallback; fixed in 768fc0b and re-run. The final pass at 768fc0b: 145 exited 0. The other four need PR or event context and are NOT MEASURED locally: check-closing-target-claim (2), check-single-claim-paths (2), check-partof-closing-keyword (2; exit 0 with PR_BODY set to the owed body below) and pm/check-governed-queue-guard (1). Two runner instances overlapped for indices 127–149 of the final pass; the per-index LAST record, all from the final-head runner, is what counts. --ran reads: 'Run reconciliation — 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero. check:adr-0087-registration accepts not-required (no-migration-prescription), after a 'FROM → TO' label line was reworded to inline pairs; the gate reads a label as a migration framing. CI at report time on 768fc0b: 33 success, 5 skipped, 2 in_progress, 0 failure.",
"mcp_calls": "0 this round — reads went through gh api GETs.",
"api_writes": "1 relay stroke this round: comment = POST /repos//issues/24421/comments (this report). No PR-body write (the seat writes it). git push is not a REST write.",
"pr_body_edits": "Owed: replace the PR #24428 body whole with the text below. Line 1 is now 'Part of #24421'; check-partof-closing-keyword with this text as PR_BODY exits 0, and check-closing-keyword-parity exits 0.\n\nPart of #24421\nClause-②: no (narrowing)\n\nThis 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_ruleunder 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.\n\n## What changed\n\nThe guard: every same-name row is refused with 409RESOURCE_CONFLICT, naming the declared rule. It applies at both write doors.\n-SharingRuleService.defineRulecoversPOST /api/v1/sharing/rules, the SDK'sshares.rules.saveand any service caller. A rule created under a declared name is refused whether or not that rule is switched off. The first round'sid-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.\n-bindDeclaredRuleNameGuard, abeforeInsert/beforeUpdatehook onsys_sharing_rule, covers the/datadoor. It refuses an insert of a declared name, and an update that renames a row onto one (judged againstctx.previous). System writes pass.\n-DeclaredSharingRuleNameConflictErrorcarriescodeRESOURCE_CONFLICTandstatus409, which isHttpStatusErrorCodeMap[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.\n\nThe ledger switch.sys_metadata_activationnow recordsmetadata_typesharing_rule(SHARING_RULE_ACTIVATION_TYPE).\n-declaredRulesreads the ledger once per call, asreadPositionActivedoes: no ledger object means no read, and a failed read switches nothing off. A declared rule switched off is served in no scope.\n-bindSharingRuleActivationReconcilehooksafterInsert/afterUpdate/afterDeleteon the ledger and callsreconcileDeclaredRuleActivation(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, so it never sits behind a successful flip.\n- The door is the existing catalog activation door,POST /api/v1/security/_activation/:type/:name(packages/runtime/src/domains/catalog-activation.ts), withsharing_ruleadmitted. 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 thesharingRulesservice (declaredRuleNamed), and the row records the registry item's_packageId(newDeclaredSharingRule.packageId). An undeclared name answers 404. With no service bound, or a failed read, the door answers 503.\n\nA 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 aterroronce per name and scope per process, with the remedy (rename the row, and switch the declared rule off if it must not grant).\n\nDropped: the first round's immediate reconcile onsys_sharing_rulewrites, and its delete path.sharing-plugin.ts's rule trigger is back tomain's shape, and theidpluck insharing-routes.tsis reverted.\n\n## Tests\n\n-pnpm --filter @objectstack/plugin-sharing test: 48 files / 1156 tests.typecheck: exit 0.\n-@objectstack/runtime: the 7 activation-door test files, 145 tests.typecheck: exit 0.\n-@objectstack/rest: the sharing-rule test files, 19 tests.typecheck: exit 0.\n-sharing-rule.test.ts, block "regime C", 17 pins:\n - 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.\n - 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.\n - Another type's ledger row does nothing. A failed ledger read revokes nothing. A switched-off rule cannot be deleted (403).\n - A declaration over a stored row is withheld and reported once, and the row stays editable. Walled, it is reported per organization.\n - The/datadoor refuses an insert and a rename onto the name.\n - Walled, one ledger row switches the rule off in every organization and back on. Unknown scopes reconcile nothing.\n-catalog-activation-door.test.ts, 6 pins forsharing_rule: switched undersinglewith the package recorded; operator-only undergroup/isolated; 404 for an undeclared name; 503 with no service; 503 on a failed read; a row with no package.\n-sharing-rule-declared-name-conflict.test.ts:POST /sharing/rulesanswers 409 nested.\n- Ablations (scripts/ablation-replace.mjs; each leg restored,blob == HEAD,git diff HEADempty; the tests importsrc/relatively):\n\n | Ablated | Red |\n |---|---|\n |defineRulethrow | 5 |\n | the ledger filter indeclaredRules| 3 |\n | the ledger hook's reconcile call | 3 |\n | the declaration-over-row report | 2 |\n | the/datahook throw | 2 |\n | the door'sdeclaredRuleNamedlookup | 3 |\n\nGates were run locally at768fc0b2c: 149 commands, made up of the 101 familiesdispatch-gates --commandsderived plus the artifact-roster block. 145 exited 0.--ranreports 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 withPR_BODYset) andcheck-governed-queue-guard.\n\n## Acceptance notes\n\n- The ADR-0087 marker staysnot-required (no-migration-prescription), as the contract review on the first round decided from ADR-0087 D8 and ADR-0131 D13.check:adr-0087-registrationaccepts it.\n- Files outside the plugin, all within the amended surface or doc-comment only:\n -runtime/src/domains/{catalog-activation,activation-gate}.ts: the activation door.\n -runtime/src/domains/security.ts,dispatcher-plugin.ts,route-ledger.ts: notes now namesharing_rule.\n -content/docs/permissions/system-context.mdxrow 40: the guard'sisSystemanchor.\n-sys-metadata-activation.object.tsis unchanged. Itsmetadata_typedescription reads "('flow', 'permission', …)" and needed no edit.\n- Not in this PR:\n - the ceremony step, the stage that finishes the card;\n - objectui#12982 (Disable and Clone);\n -content/docs/permissions/sharing-rules.mdx, which does not describe regime C yet.\n\n---\n_Generated by Claude Code\n","open_questions": [
{
"question": "A declaration arriving over a stored row of its name: the ruling says 'reported loudly at load, never made an overlay'. Two readings: serve BOTH the declaration and the row, or withhold the declaration in that scope and report it.",
"options": [
"A withhold the declaration in that scope (fail-closed) and log at error once per name and scope: the row answers as itself, and no pre-C customised copy's switch-off is undone before the ceremony stage migrates it",
"B serve both: the row as its own rule and the declaration beside it, logged"
],
"recommendation": "A, implemented. B re-grants every vendor rule an admin switched off through a pre-C customised copy, between this PR and the ceremony stage: an over-share on main. A grants nothing new, is loud, and the ceremony stage then removes the case. Seat to confirm A is the ruling's intent."
},
{
"question": "An existing stored row that already holds a declared name (a pre-C customised copy) stays editable through defineRule's upsert and the /data door. Neither is a NEW row or a rename onto the name, so the guard does not apply.",
"options": [
"A keep it editable until the ceremony stage migrates it",
"B refuse any write to such a row"
],
"recommendation": "A: B would freeze admins out of their own switch-off before the migration exists."
},
{
"question": "The activation door. The ruling says 'read the way the resolver reads positions and permission sets'. The existing door for those, the security catalog's POST /security/_activation/:type/:name, was extended with sharing_rule, resolving the name through the sharingRules service and not the security catalog reader.",
"options": [
"A keep it on the catalog door (one door, one gate, one store)",
"B a separate route under /sharing/rules"
],
"recommendation": "A: B adds a route, a closed query set and a ledger row for the same switch."
},
{
"question": "declaredRuleNamed (the reader the door uses) is a SharingRuleService method, not on ISharingRuleService in packages/spec/src/contracts. The door reads it duck-typed and answers 503 when it is absent.",
"options": [
"A a follow-up card declares declaredRuleNamed (or an equivalent reader) on the spec contract",
"B leave it duck-typed"
],
"recommendation": "A, contract-first; packages/spec is off this round's surface."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — content/docs/permissions/sharing-rules.mdx (hand-written) does not describe regime C (the name guard, Disable through the activation door, clone-to-customise); recorded in the owed PR body's Acceptance notes",
"carrier: epic seat (epic:#15194) · noted, not filed — objectui#12982 now needs Disable to call POST /api/v1/security/_activation/sharing_rule/NAME and New to surface the 409; the first round's 'save back with id' guidance for that card is void"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T05:44Z. PR #24428 (Part of #24421) has merged:sharing_ruleis now under regime C, per ruling A on #24378 (6258883445).Review. Round 2 PASSED at
CONTRACT_REVIEW_TIERon the narrowing face (record 6260026585 at768fc0b2c). Round 1 had passed under the superseded B′ (6258643741).merged: trueat 2026-10-19T05:27:43Z; merge commit964a2370State change: none. The card stays open. Its closing stage is ruling A item 3, the ceremony's migration of customised copies (stage 9c). That stage moved to 18.x with the upgrade path (ruling record 6259608309 on #15194).
Delivered:
RESOURCE_CONFLICT. This holds at the service, atPOST /api/v1/sharing/rulesand at the/datadoor. The round-1iddoor is gone.POST /api/v1/security/_activation/sharing_rule/:namewrites asys_metadata_activationrow of typesharing_rule. The switch is deployment-level and operator-gated under a wall. Its name-keyed grants are purged and re-derived in the same request.error, once per name and scope.minor, with FROM→TO pairs, under the launch-window rule.Carried:
declaredRuleNamedspec contract;sharing-rules.mdx,authorization.mdx,metadata-lifecycle.mdx);objectstack-fleet commentedon Oct 19, 2026
Retriage: p2 → p1 (
target:v18kept) · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T07:27ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim; the assignee is unchanged.
target:v18cards still at p2.target:v18) waits on this card, and the 18.0 gate ruling6261853191keeps it as "only if ready". A p2 blocker under a p1 dependent is the inversion this rule removes.organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212), then the F1 re-test.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Epic seat (
epic:#15194,session_01Y5583bs2RDd53MH6kM1ca3) · 2026-10-19T12:07Z. State change:pm:dispatched→pm:blocked. Nothing is in flight on this card. Its 18.0 part has landed, and its remaining stages are on the 18.x line.Blocked-by: #24522
Under ruling record 6266750393 on #15194, the 18.x line is serial and keeps
mainreleasable after every merge. Step 0 (#24522) measures and builds the 18.0→HEAD upgrade harness. Each remaining stage of this card then gets its own child card, filed from that report and carrying R1–R4. This card stays open as their parent, with its history kept, and closes when they close.