Skip to content

Commit 964a237

Browse files
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)
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](https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9524666 commit 964a237

13 files changed

Lines changed: 782 additions & 20 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(plugin-sharing,runtime)!: a declared sharing rule is read-only and clonable — no rule, new or renamed, can take its name, and it is switched off through the activation ledger, which revokes its grants at once
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no spec key, export, option or stored metadata shape is added, removed or renamed, so `objectstack migrate meta` has nothing to rewrite; what changes is the accept set of two write doors, whose remedy is another name for the caller's rule plus one activation call -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired.
15+
16+
What is refused now:
17+
18+
- `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/<name>` and `{ "enabled": false }`.
19+
- A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same.
20+
21+
What is added:
22+
23+
- **The switch.** `POST /api/v1/security/_activation/sharing_rule/:name` writes the declared rule's activation row, behind the same authority as the other activation doors: `manage_metadata`, and the platform operator under a wall, because the row covers every organization. A declared rule switched off is served nowhere. The write revokes every grant that carries the rule's name in the same request, and switching it back on re-derives them. The name must be a declared rule (`404` otherwise; a rule your organization authored keeps its own `active` field), and with no sharing-rule service bound the door answers `503`.
24+
- **A declaration over a stored row is reported, never made an overlay.** When a package declares a rule whose name a `sys_sharing_rule` row already holds in a scope (a package upgrade, or a copy customised before this release), the row keeps answering as its own rule. The declaration is not served beside it, and that is logged at `error` once per name and scope per process, with the remedy.
25+
26+
Unchanged: rules whose name no declaration holds are created, renamed and switched as before. A stored row that already holds a declared name stays editable as itself. `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule is still `403 PERMISSION_DENIED`, and is now answered the same way when the rule is switched off.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ One of the largest consumers of the flag;
159159
| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` |
160160
| 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink`, `#revokeLink`, `#listLinks` |
161161
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` |
162-
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule` |
162+
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, the platform-global-rule delete guard, and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule`, `#bindDeclaredRuleNameGuard` |
163163

164164
### 4. Approvals, attachments, comments, knowledge
165165

‎packages/plugins/plugin-sharing/src/declared-sharing-rules.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ export interface DeclaredSharingRule {
151151
recipientId: string;
152152
accessLevel: ShareAccessLevel;
153153
active: boolean;
154+
/**
155+
* The package that ships the declaration (the registry item's `_packageId`),
156+
* when it names one — the provenance an activation-ledger row records
157+
* (`sys_metadata_activation.package_id`). Absent for a declaration no package
158+
* names.
159+
*/
160+
packageId?: string;
154161
}
155162

156163
/** Why a declaration is not served: the compiler's own refusal, or a shape the runtime has no slot for. */
@@ -217,6 +224,7 @@ export function translateDeclaredSharingRule(
217224
recipientId: String(r.sharedWith.value),
218225
accessLevel: (r.accessLevel ?? 'read') as ShareAccessLevel,
219226
active: r.active !== false,
227+
...(typeof r._packageId === 'string' && r._packageId ? { packageId: r._packageId } : {}),
220228
},
221229
};
222230
}

‎packages/plugins/plugin-sharing/src/sharing-plugin.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
type SharingSecurityProbe,
5757
type SharingTenancyProbe,
5858
} from './sharing-service.js';
59-
import { SharingRuleService } from './sharing-rule-service.js';
59+
import { SharingRuleService, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile } from './sharing-rule-service.js';
6060
import { ShareLinkService } from './share-link-service.js';
6161
import { registerShareLinkRoutes } from './share-link-routes.js';
6262
import { bindRuleHooks, unbindAllRuleHooks, bindRuleCriteriaGuard, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js';
@@ -849,6 +849,14 @@ export class SharingServicePlugin implements Plugin {
849849
// match-all criteria gate has to sit on the table itself too.
850850
bindRuleCriteriaGuard(engine, ctx.logger as any);
851851

852+
// [ADR-0131 D6 regime C] …and so must the refusal of a row, new or
853+
// renamed, that takes a declared rule's name: `defineRule` makes it
854+
// for its own callers. A declared rule is customised by a clone
855+
// under another name plus the declared rule switched off in the
856+
// activation ledger, whose writes reconcile its grants at once.
857+
bindDeclaredRuleNameGuard(engine, this.ruleService);
858+
bindSharingRuleActivationReconcile(engine, this.ruleService);
859+
852860
// [#2909 T1] Stamp `customized` on admin edits of seeded rules so
853861
// a customized copy keeps answering for its name — the declared
854862
// rule is not served beside it (ADR-0131 D2), and the ownership

0 commit comments

Comments
 (0)