Skip to content

Commit 5e97907

Browse files
feat(plugin-sharing,cli)!: declared sharing rules are read from the registry; the ownership ceremony deletes their seeded copies (#24376)
Fixes #23952 Clause-②: no (narrowing) C3 stage 9 of ADR-0131 (#15204), in the order ruling β (6216760903) sets. Declared sharing rules are read from the registry, and `bootstrapDeclaredSharingRules` retires. The ownership ceremony renames the grants of the copies that seeder wrote, then deletes the copies. `DECLARED_SHARING_RULE_SEEDER_RUNS` flips to `false` in this same change, as the #23948 hand-off (6220693269) orders. This is one PR, not 9a then 9b: 2,615 changed lines (+1,881 / -734, 28 files) after patch round 1, under the 3,000 line. ## What changes **`@objectstack/plugin-sharing`: enforcement reads declared rules from the registry.** - `declared-sharing-rules.ts` replaces `bootstrap-declared-sharing-rules.ts`. `createDeclaredSharingRuleSource` reads `engine.registry.listItems('sharing_rule')`, and the metadata service's `list('sharing_rule')` only when the registry holds no rule. That is the same two readers, in the same order, the seeder used. It translates each declaration into the runtime shape, exactly as the seeder did (ADR-0057 D6). Translation is memoised per definition object. A rule that is not served says so once per rule and cause per process. - `SharingRuleService` takes the source (`declaredRules`) and projects each declared rule into the scopes the plugin sets from the posture (`setDeclaredRuleScopes` / `addDeclaredRuleScope`). Under `single` there is one organization-less scope. Under a wall there is one scope per organization, plus an organization created after boot. These are the seeder's passes (#10103), as reads. - Every read of the rule set includes the projections: `listRules` (which the hooks, the object and boot reconciles, the business-unit recompute and the rebind all go through), `getRule`, `evaluateRule` and the orphaned-grant sweep. - A projection's id is its name, so its grants carry `source_id` = the rule's name (ruling β item 5 (c)). Under a wall each organization's projection reads and writes only its own grants: `ruleGrantScope` adds the grant's `organization_id`. An organization-authored rule keeps its row and its id-based `source_id`. - A stored row holding the declared name answers for it, and the declaration is not served beside it. Under `single` that is a row of that name in any organization, and under a wall that organization's row. This is the row the seeder's upsert would have found: an organization's own rule, a customized seeded copy, or a seeded copy the ceremony has not yet deleted. The seeder never overwrote admin-authored or customized rows, and this keeps that. - `deleteRule` on a declared rule answers `PERMISSION_DENIED`. It has no row, and is changed in code. - The boot no longer writes any `sys_sharing_rule` row for a declaration. **`@objectstack/cli`: the ceremony executes the `declared-rule-mirrors` departure.** - `DECLARED_SHARING_RULE_SEEDER_RUNS = false`, so the mirrors hold certification again. - The `mirror-deletion` step ends with `runDeclaredRuleMirrorDeletion`, which works in the ruled order: 1. It reads the mirrors with the departure's own selector (`managed_by` package or platform, `customized` not true), under every organization. 2. It refuses, writing nothing, if grants still name a mirror whose rule no installed package declares. 3. It renames every `sys_record_share` grant naming a mirror by row id to the rule's name. 4. It verifies that no grant names a mirror. 5. It deletes the mirrors by id, with the selector re-asserted, and counts them to zero. - The step runs again on a checkpointed ceremony while a mirror stands. That covers a database that completed the ceremony while the seeder ran (hotcrm's `dev.db`). - The declared names come from a new `DeclaredSharingRulesPort`. The command answers it from the booted registry, which is what enforcement reads. **`@objectstack/lint`.** The sharing-rule enforceability verdict and `os explain` now say a declared rule whose condition does not lower is "never served", not "never seeded". **Pins flipped (the whole-repo grep for the seeder's behaviour):** - `organization-ownership.declared-rule-mirrors.integration.test.ts` case 1: a served boot writes **zero** rows and the service serves every declared rule under its name. Case 2: eleven mirrors with grants are renamed and then deleted, and every grant resolves. The result is `[complete, blocking, advisory] = [true, 0, 0]` and the marker is verified. Then the guarded served boot serves the declared rules. - `organization-ownership-apply.declared-rule-mirrors.test.ts`, which also covers the checkpointed re-run, the undeclared-with-grants block, the grant-less undeclared delete, the no-registry block, the walled copy and the seeding-release advisory leg. - `organization-ownership-apply.test.ts`. - The remote-face case (skipped without `sqld`). - `standalone-stack-seeder-declaration-copy.test.ts` now reads the served rules, and `sys_sharing_rule` is empty. - The three dogfood files that read the seeded row. - The authz matrix row. - The `sharing_rule` liveness producer pointers. ## Landing points outside the claimed file surface, and why - **`packages/spec/liveness/sharing_rule.json` and `packages/spec/scripts/liveness/proof-registry.mts`.** These are evidence pointers only: no schema, no type and no generated artifact. `check:liveness` resolves every `path#Symbol` producer anchor, and nine of them named `bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules`. Retiring the seeder without moving them reds that gate. The card's ⛔ on `packages/spec` concerns registering `sharing_rule` as a metadata kind, and none was needed (below). This is declared as a deviation in the report. - **`packages/lint`.** Its author-facing text named the seeder and said "never seeded". - **`packages/runtime`** (one test) and **`packages/qa/dogfood`.** Pins on the seeder's rows. ## Measurements (mechanism assumptions A1–A7) - **A1 holds.** Every enforcement read went through `sys_sharing_rule` (`listRules` / `getRule` / `findRuleRowByName` / the sweep), and grants carried `source_id: rule.id`. Every consumer (`rule-hooks`, `bu-tree-recompute`, the backfill, the rebind) lists through `listRules`, so the projections reach all of them. - **A2: no `packages/spec` change is needed.** The ObjectQL `SchemaRegistry` already holds `sharing_rule` items: `METADATA_ARRAY_KEYS` lists `sharingRules`, and `AppPlugin.init` registers forward-converted bytes. The metadata service binds the type through `UNREGISTERED_KIND_SCHEMAS`. Both are the seeder's own readers. - **A3.** After #22994 the two copies differ only on the parse axis. On the registry side, `condition` is a bare string where the door has `{ dialect, source }`, and `active` is absent. `compileCelToFilter` takes both condition shapes, and the reader reads `active !== false`, which is the schema default. Enforcement reads the registry copy, as the seeder did. - **A4: item (b) is not settled.** ADR-0131 D6 charts regimes O (`view`, `dashboard`, `report`, `translation`, `email_template`), C (`flow`, `action`, …) and E (`object`, `app`). `sharing_rule` is in none of them. ADR-0057 D6 speaks only to seeding. No disable mechanism was built. See the report's `open_questions`. - **A5.** Grants are written by `SharingService.grant` with `sourceId: rule.id`. Its upsert keys on `(object, record, recipient_type, recipient, source)`, not on `source_id`, so the ceremony's rename cannot create a duplicate. They are read by `reconcile`, `reconcileForRecord`, `purgeGrants`, `revokeRuleGrantsForRetiredRecipients` and the sweep, all through `ruleGrantScope`. An organization-authored rule keeps its id-based `source_id`, which is pinned. - **A6 (#14491).** Enforcement reads the registry copy, un-parsed, exactly as the seeder did. #14491's parse axis is unchanged. Its consequence moves from the seeded row to the served rule, and is absorbed for `condition` and `active`. - **A7.** 2,338 changed lines at the first head, measured by `dispatch-gates`; 2,615 (+1,881 / -734, 28 files) after patch round 1. ## Evidence - `@objectstack/plugin-sharing`: test 48 files / 1131 passed, and typecheck 0 (including the test typecheck). - `@objectstack/lint`: 144 / 6858 passed, and typecheck 0. - `@objectstack/cli`: - typecheck 0. - `--project unit`: 310 files. Two needed the CLI's own `dist` built first and passed after `pnpm --filter @objectstack/cli build`. - Every `organization-ownership*` suite, both tiers: 17 passed, 1 skipped (`sqld`). - `@objectstack/runtime`: the declaration-copy pin 13/13, and typecheck 0. - `@objectstack/dogfood`: typecheck 0. The rbac-seeding, d3-d4, org-scoped-listing, authz-conformance, criteria-required and org-less-caller suites passed. - These suites were re-run after merging `origin/main` (e3658ab). - **Ablation** (`scripts/ablation-replace.mjs`, restored to HEAD, `git diff HEAD` empty both times): - `declaredRules` returning `[]` in `listRules` turned 7 of the new registry-read pins red. - Skipping the ceremony's rename turned 6 of the stage-9 pins red. - **Gates.** `dispatch-gates --commands` derived 82 families. `--ran` reports 82 run, 0 NOT-MEASURED, on e3658ab. - `check:doc-authoring` first went red on `#23952` in the departure label. See the first acceptance note. - `check:slot-lookup` first went red on three new untyped service lookups. They are typed now. - `check:dual-build-cjs-loads` passed once the eight packages it reads were built. - `check:plugin-teardown-shape --self-test` passed after fetching its pinned commit into the shallow clone. ## Acceptance notes - **Narrowings (FROM → TO), BREAKING, shipped as `minor` in the launch window.** `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule: 204 → 403 `PERMISSION_DENIED` (remedy: change or remove the declaration in its package). `sys_sharing_rule`: one `managed_by: package` row per declared rule → none (remedy: `GET /api/v1/sharing/rules` lists declared rules; run `os migrate organization-ownership --apply` with a fresh plan). ADR-0087: `registered sharing-rule-declared-mirror-retired`. This line and `Clause-②: no (narrowing)` above were written by the epic seat from the dev's patch-round-1 report (6253831516 on #23952), which names this edit. - **Ruling β item 6 vs. `check:doc-authoring`.** Item 6 asks the `declared-rule-mirrors` post-check label to name this card by number. The gate refuses a tracker id in a runtime string (maintainer ruling 2026-08-12, and its baseline is maintainer-only). The label names the step and the stage instead: "deleted by the mirror-deletion step (ADR-0131 C3 stage 9)". The card number sits in the adjacent comment. After this change the rows no longer wait for a later card: the ceremony itself removes them. - **Walled databases.** The step also deletes organization-stamped uncustomized copies, the walled seeder's per-organization rows, which are not NULL rows of the departure. The selector is the departure's, and item 4 names the same population without restricting it to NULL rows. Left standing, those copies would answer for their names with content frozen at the last seed. - **Undeclared mirrors.** A mirror whose rule no installed package declares is deleted when no grant names it, like every fate-2 mirror. When grants still name it, the ceremony blocks and names its remedy: deleting it would withdraw that access on the operator's behalf. - **Setup's Sharing Rules list.** It is the generic `sys_sharing_rule` object view, so it no longer shows declared rules. `GET /api/v1/sharing/rules` lists both kinds. The consumer follow-up is objectstack-ai/objectui#12982, dispatched after this PR merges. - **`DefineSharingRuleInput.managedBy`.** The spec contract keeps the `package` / `platform` input. The seeder was its only producer, so it now has none. `defineRule` keeps honouring it, so nothing narrows. - **Same-name rows created at runtime.** Such a row answers for a declared name from its creation. The declaration's name-keyed grants in that scope are swept at the next boot, not at once. What switching off a vendor's declared rule should mean is decision #24378. - `#14491 is not addressed here`: its parse axis is unchanged (A6). --- _Generated by [Claude Code](https://claude.ai/code/session_01YHtySYfURHqg298wCCJ8wj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf16a0d commit 5e97907

28 files changed

Lines changed: 1881 additions & 734 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/lint': patch
5+
---
6+
7+
feat(plugin-sharing,cli)!: declared sharing rules are read from the registry — the boot writes no `sys_sharing_rule` row for them, and the 17 → 18 ownership ceremony deletes the copies the retired seeder wrote
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: registered sharing-rule-declared-mirror-retired -->
12+
13+
The narrowings, FROM → TO, each with its one-line remedy:
14+
15+
- `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule — FROM `204`, the seeded row deleted (and written again by the next boot), TO `403 PERMISSION_DENIED`. Remedy: change or remove the rule in its declaration in the package; an organization's own rule is deleted as before.
16+
- `sys_sharing_rule` — FROM one `managed_by: package` row per declared rule, TO none: the boot writes none and the ownership ceremony deletes the copies the seeder wrote (Setup's Sharing Rules object list, which lists rows, no longer shows declared rules). Remedy: read declared rules through `GET /api/v1/sharing/rules`, which lists them beside the organization's own; on an upgraded database run `os migrate organization-ownership --apply` with a fresh plan.
17+
18+
The retirement note for the seeder is the ADR-0087 semantic entry `sharing-rule-declared-mirror-retired`.
19+
20+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.
21+
22+
`@objectstack/plugin-sharing` no longer copies a stack's declared `sharingRules` into `sys_sharing_rule` at boot. ADR-0131 D2 retires that seeder (`bootstrapDeclaredSharingRules`). The sharing-rule service reads each declared rule from the registry whenever it reads the rule set: listing, getting, evaluating, the per-record hooks, the boot backfill and the orphaned-grant sweep. Rules an organization authors keep their `sys_sharing_rule` rows.
23+
24+
- **A declared rule still grants, and stops granting when its declaration goes.** It is translated from the registry's copy exactly as the seeder translated it: the CEL `condition` is lowered to a `criteria` filter, and `sharedWith` becomes the recipient. A condition that does not lower is not served, and never as a match-all. It says so in one WARN line per rule per process. A declared rule's id is its NAME, and the grants it materialises carry the name as `sys_record_share.source_id`. Remove the declaration and the next boot's orphaned-grant sweep revokes those grants.
25+
- **Scopes follow the posture, as the seeder's passes did.** Under `single` a declared rule is served once, organization-less, so every organization reads it. Under a wall it is served once per organization — every organization, read page by page. An organization created after boot is served from its creation on. If the organizations cannot be read at boot, no declared rule is served until the next boot, and the boot's orphaned-grant sweep revokes none of their grants meanwhile: a failed read is never a revocation.
26+
- **A stored row of the same name answers for it.** Under `single` that is a row of that name in any organization, and under a wall that organization's own row. Such a row is an organization's own rule, a seeded copy an administrator customized, or a seeded copy the ceremony has not deleted yet. The declared rule is not served beside it, the same as while the seeder ran: it never overwrote an admin-authored or customized row.
27+
- **`DELETE /api/v1/sharing/rules/:idOrName` refuses a declared rule** with `403 PERMISSION_DENIED`. It has no row: it is changed or removed in its declaration. Before this release a platform administrator could delete the seeded row, and the next boot wrote it again. An organization's own rule is deleted as before.
28+
- **Setup's Sharing Rules object list no longer shows declared rules.** It lists `sys_sharing_rule` rows. `GET /api/v1/sharing/rules` lists both.
29+
30+
`@objectstack/cli` — **`os migrate organization-ownership --apply` deletes the declared-rule mirrors.** The `declared-rule-mirrors` rows on `sys_sharing_rule` are the seeder's uncustomized copies (`managed_by` `package` or `platform`, `customized` not true). They now block certification until the ceremony's `mirror-deletion` step removes them. It works in the ruled order. It renames every grant naming a mirror by row id (`sys_record_share.source_id`, `source = 'rule'`) to the rule's name and verifies that no grant names a mirror any more. Only then does it delete the mirrors. Under a wall it deletes the organization-stamped copies too.
31+
- A mirror whose rule no installed package declares, and that grants still name, blocks with nothing written. The blocker names the rows and the remedy. A mirror no grant names is deleted.
32+
- The step also runs on a database whose ceremony already completed while an earlier release still seeded the rows, where they were advisory. Re-run `--apply` with a fresh plan.
33+
- `DECLARED_SHARING_RULE_SEEDER_RUNS` is now `false`. A customized copy and an organization's own rule stay. Under `single` they take the Default Organization, as before.
34+
35+
`@objectstack/lint` — the sharing-rule enforceability verdicts and `os explain` say what now happens to a declared rule whose condition does not lower. It is "never served", not "never seeded", because it is never written to `sys_sharing_rule`. The rule ids, severities, paths and what each rule accepts or refuses are unchanged.

0 commit comments

Comments
 (0)