Repository navigation
Commit 5e97907
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
File tree
- .changeset
- packages
- cli/src
- commands/migrate
- utils
- lint/src
- plugins/plugin-sharing/src
- qa/dogfood/test
- runtime/src
- spec
- liveness
- scripts/liveness
- src/migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
0 commit comments