Skip to content

Commit 7d7943d

Browse files
feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) (#22143)
Part of #15196 Clause-②: no **What this does.** Grants stored in `sys_user_permission_set` before the `permission_set` name column existed carry `NULL` there. On boot, `@objectstack/plugin-security` now fills that column once on each such grant. The value is the `name` of the `sys_permission_set` row that the grant's own `permission_set_id` names, read inside the grant's own organization and checked through the security catalog read before it is written. A grant this pass cannot name keeps `NULL` and is reported in the boot log. The pass records its verdict once in `sys_migration`. **Clause-② is `no`:** the column and its rule (system-written, agreeing with the id) already shipped with stage S4a. This pass brings older rows under that rule. No accepted or refused input changes, no reader reads the name yet, and no surface is added: the module is not exported from the package. Stage S4b of ADR-0131 C2 (claim amendment `6049507458` on the card). Not in this PR: - no reader changes (S5); - no change to the S4a hooks or to the object definition; - no drop of the id column (C8); - nothing in `packages/spec`, nothing in `plugin-auth`; - no row deleted, no grant moved. ## What changes - **`grant-permission-set-name-backfill.ts`** (new, `plugin-security`). For every grant whose name is `NULL` or blank (the whole set is read, ordered by id, before anything is written): 1. **The set row its id names, read inside the grant's wall.** The read runs under the grant's organization (`seedCtx`), so the driver's tenant scope returns that organization's rows and the organization-less ones. Then the resolver's own grant rule is asked of the set row: an organization-less set applies everywhere, and an organization's set applies only to a grant of that same organization. So an organization-less grant may name only an organization-less set. 2. **The name is verified through S1's catalog read** (`createSecurityCatalogReader` from `@objectstack/core`). A name the catalog does not resolve is not written. 3. **Written as a system update of the name column alone,** under the grant's organization. - **Report, never guess.** Each category below is logged once, with a count and grant row ids only. No name or id from another organization is logged. - an id that names no set row: `warn`; - an id whose set row is outside the grant's organization: `warn`; - a set row whose name the catalog read does not resolve: `error`, with the remedy; - a name write that did not land: `error`; - a read that did not happen (the grant scan, a set row, the catalog's `AuthzStoreUnavailableError`): the pass stops and says which read failed. It never reads an outage as "no such set". - **Once, and remembered.** The ledger row follows `plugin-auth`'s `membership-backfill-ledger.ts`, which this PR reads but does not edit: - one row with its own id, `adr-0131-grant-permission-set-name-backfill`; - `verified_at: null`, since nothing gates on it; - `blocking: 0`; - `details` holds the counts. - A verdict is recorded only when a later pass could not decide differently: everything was named, or names no set row, or names a set outside the grant's organization. An unresolved name, a failed write or a stopped pass leaves it unrecorded, so the next boot judges again. A concurrent boot that lands the same id first counts as recorded. - **One departure from that module, on purpose:** without a readable ledger, this pass still runs. That module does not run without its ledger, because its second run would decide membership again. This pass only fills a `NULL` with the name the row's own id already names, so a second run writes nothing new. The cost is one scan per boot, and a `warn` that says so. - **Boot wiring** in `SecurityPlugin.start`, beside the platform bootstrap (near `:4816`; disjoint from stage S2's manifest edit near `:1448`). It runs at `kernel:bootstrapped`, inside a `try` that only warns, so the boot never fails on it. - Outside the claimed file list, both mechanical: - `scripts/adr-anchors/…grant-permission-set-name-backfill.ts.json` anchors the module to ADR-0131. - `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` were regenerated with `tenant-audit-census --write`. The module adds two engine write call sites, so the count goes from main's 232 to 234, and the page's prose figures follow. They were regenerated again on the merge of `main` at `aa9447c70b`, whose own regeneration had moved the base from 233 to 232. ## The write path, and why (measured) Measured on a real `ObjectQL` engine over `SqlDriver` with the real `SecurityPlugin` started, under `single` and `isolated`. The S4a hooks run on the backfill's system write. Results: | system update under the grant's organization | result | |---|---| | `{ id, permission_set: OTHER_SET_NAME }` | refused, `VALIDATION_FAILED` | | `{ id, permission_set: AGREEING_NAME }` (this PR's write) | lands | | `{ id, permission_set_id: SAME_ID }`, no name | the hook stamps the name | | `{ id, OTHER_COLUMN: VALUE }` | name stays `NULL` | So the hooks fill the name by themselves only on a write that carries the id. This PR writes the verified name, not an id echo, for three reasons: - Two independent checks agree before a name lands: the catalog read here, and the hook's re-derivation from the stored id. A grant whose id moved between this pass's read and its write is refused, not mislabelled. - The write carries no `permission_set_id`. So nothing keyed on that column re-judges the grant: `plugin-auth`'s last-administrator guard lists it in `GRANT_STANDING_KEYS`. - The name lands with the hooks unbound too (pinned). **The hook is not this pass's wall.** For a system write whose stored id the hook cannot resolve inside the writer's wall, the hook stands down and lets the value land. Ablation A2b below shows that a name carried across organizations lands through the hook. ## The tenant wall, against today's resolver (measured) The grant rule: `resolveUserAuthzGrants` was called for a user holding one grant per case, with the active organization set to the grant's organization (A), to another organization (B), or to none. The same in `single`, `group` and `isolated`: | grant org → set row org | resolver today: A / B / none | this pass | |---|---|---| | A → A | grants / – / – | names it | | A → organization-less | grants / – / – | names it | | organization-less → organization-less | grants / grants / grants | names it | | organization-less → A | grants / grants / grants | reported, `NULL` | | A → B | grants / – / – | reported, `NULL` | Inside the wall the pass matches today's resolution exactly. The last two rows differ: today's resolver reads a grant's set row by id without a wall (`resolve-authz-context.ts` §6b), so those grants grant across organizations by id. This order's rule is that a name never crosses organizations, so those grants are reported and left unnamed. Readers still read the id, so their grants do not move here. What they resolve to once readers switch to the name is S5's question (Acceptance notes). ## When it runs (measured) At `kernel:bootstrapped`: the kernel fires it only after every `kernel:ready` handler has settled, on `ObjectKernel` and on `LiteKernel`. Code-declared and environment catalog items are in the registry by `kernel:ready`. A real `LiteKernel` boot is pinned: - a provider plugin whose `kernel:ready` handler registers a permission set after `SecurityPlugin`'s own handler; - a grant on that set, stored before the boot. The grant is named on the same boot, and the verdict is recorded. With the wiring moved to `kernel:ready` (A6), the name reads missing. A definition that arrives after the boot, such as a package installed into the running process, cannot turn into a recorded "missing". The unresolved name is reported at `error`, the verdict stays unrecorded, and the next boot judges it again (pinned). ## Pins All pins are in `grant-permission-set-name-backfill.test.ts`, on a real engine with the real plugin: - Every backfilled name equals the set row its id names and resolves through S1's read, in `single` and `isolated`. - **Grant equivalence:** the platform administrator, organization administrator, member and agent resolve to the S4a golden before and after the pass, in two postures. - An id with no set row is reported by count and grant id, and no name is guessed. - A catalog read that answers nothing: no name is written, one `error`, and the verdict is not recorded. - An unresolved-yet name: no record; after registration, the next pass names it. - A failed name write: `error`, and no record. - A cross-organization id is not written, and nothing of that organization is logged. Two postures. - A second pass reads and writes nothing, and the ledger row is written once. - No ledger: the pass still names, warns, and a second pass renames nothing. - The name lands with the S4a hooks unbound. - The real-kernel late-provider boot, and the boot wiring. **Ablations.** All legs ran at `06642523e1` through `scripts/ablation-replace.mjs` in wrap mode. Each restore was proven by blob equal to `HEAD`, with an empty `git diff HEAD`. The subject is imported from `src` by a relative path, so no `dist/` leg applies. | leg | mutation | result | |---|---|---| | A1 | catalog check always passes | red 2/4 (`report, never guess`): silent catalog, unresolved-yet | | A2 | grant rule dropped, driver wall kept | red 2/2 (cross-organization, both postures): organization-less grants named | | A2b | grant rule dropped and set-row read unwalled | red 2/2: all three cross-organization grants named | | A3 | ledger read skipped | red 2/2 (`once, and remembered`): second pass returns `ran` instead of `already-run` | | A4 | an id with no set row gets the id as its name | red 1/4: expected `'ps_gone'` to be `null` | | A5 | the write also sets `valid_until` in the past | red 2/2: the grant-equivalence golden | | A6 | wiring moved to `kernel:ready` | red 1/1: the late provider's set reads missing | ## Verification Head `7f9500afd5`. That is `main` at `8fc50b7647` merged in, plus this PR. - **This file:** 15/15. `plugin-security` typecheck is green, test layer included: 0 files, 0 errors, 0 debt. - **Full `plugin-security` vitest after the merge:** 173 files, 3678 passed, 45 skipped. Its sources are byte-identical to the head except the typed test reads, which are re-run in this file. - **`core`:** typecheck green, and vitest `--project local` 78 files, 2192 passed. The closure was rebuilt first. - **Dogfood, 7 files, 64 passed and 2 skipped** (`rls-multitenant`'s own `skipIf`). The files: `security-catalog-showcase` (its walled arm included), `membership-ended-session-revoke`, `org-admin-affordance-reach`, `admin-platform-admin-standing`, `me-apps-and-everyone-baseline`, `showcase-permission-seeding` and `rls-multitenant`. All 8 real showcase boots ran the pass and recorded it, with `{"unnamed":0,"named":0,"dangling":0,"crossOrganization":0}`: a fresh boot's writers write both columns. - **Gates:** `dispatch-gates --commands` derived 104 at this head, and all 104 exited 0. `dispatch-gates --ran`: 104 run, 0 NOT-MEASURED, 0 UNRUN. - First-pass reds, fixed: `check:tenant-audit-census` (regenerated, above) and `check:query-options-erasure`. For the second, ten test reads had erased their options to `any`; they are now typed, and the test surface is back to 236. - Two `PREREQUISITE NOT MET` refusals (exit 3) were re-run after building their packages, and both exit 0. - **Lint, a narrowed run.** `eslint --no-inline-config --format json` over the 3 changed TS files: 3 linted, 0 errors, 0 warnings, none ignored. The config sets no `parserOptions.project` and no typed rule, so untouched files' verdicts are invariant. The full `pnpm lint` is CI's. - **Patch round, head `0306ca272a`** (`main` at `aa9447c70b` merged; no rebase, no force-push). The two census files conflicted with the regeneration that landed on `main`. They were resolved by regenerating with `tenant-audit-census --write` from `main`'s copy, and the prose figures were updated from the gate's own numbers: write sites 232 to 234, run-time names 78 to 80, decidably elevated 122 to 123, elevation undecidable 102 to 103. No side was hand-picked. At this head: - `check-tenant-audit-census`, `check-system-context-census` and `check-regen-pending` all exit 0; no os-regen path is touched and none is pending. - This file passes 15/15, and `plugin-security` typecheck is green after a closure rebuild. - `dispatch-gates --commands` derived the same 104 commands. All 104 exit 0 after rebuilding the packages the dist-reading gates read, and `--ran` reports 104 run, 0 NOT-MEASURED, 0 UNRUN. - **Main moved since `aa9447c70b`:** six commits. One of them (#22173) edits `security-plugin.ts` in other regions, and `git merge-tree` against it is clean. The boot refusal it adds asks for `sys_user` and `sys_member`, which the pins' engine registers. The joint build is CI's and the queue's. ## Acceptance notes - **Grants whose id names a set row outside their organization grant today by id.** The resolver's set-row read (§6b) has no wall, measured in the table above. This PR leaves those grants unnamed and lists them at boot. The reader switch will meet them: it must decide what they resolve to, and pin it. Carrier: S5a (`resolve-authz-context.ts` §6). - **A grant a system writer leaves unnamed after the ledger row lands is never backfilled.** The S4a hook stands down for a system write whose id resolves to nothing yet, and this pass is one-time. No writer in this repository leaves one (all four write both columns). D10's "verified rewrite" before the id column is dropped must count `NULL` names, not trust this ledger row. Carrier: C8. - **`persistGrantNameBackfillRecord` is a new durability seam.** It logs at `error` and is pinned, but it is not in `DURABILITY_CRITICAL_CALLEES`, because that edit is outside this stage's surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13aea18 commit 7d7943d

7 files changed

Lines changed: 1403 additions & 25 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
Grants written before `sys_user_permission_set.permission_set` existed now get their permission set's name, once, at boot (ADR-0131 D4)
6+
7+
Clause-②: no
8+
9+
- **What happens on the first boot after upgrading.** At `kernel:bootstrapped`, `@objectstack/plugin-security` fills `permission_set` on every grant that has no name yet. The name is the `name` of the `sys_permission_set` row that the grant's `permission_set_id` points at. The set row is read inside the grant's own organization: a grant of an organization may name that organization's set or an organization-less one, and an organization-less grant may name only an organization-less set. Each name is checked in the security catalog before it is written. Only the name column is written: no id changes, no grant is moved and no row is deleted. No principal's grants change, because readers still resolve grants from `permission_set_id`.
10+
- **What is left unnamed, and reported in the boot log by count and grant id.** A grant whose id names no set row (`warn`). A grant whose id names another organization's set row (`warn`); nothing about that organization is logged. A grant whose set row carries a name the security catalog does not hold at that boot (`error`): register the permission set definition, or re-point the grant.
11+
- **It runs once.** When the pass has nothing left that a later boot could decide differently, it records its verdict in `sys_migration` under the id `adr-0131-grant-permission-set-name-backfill`, and later boots skip it. A grant the catalog could not verify, or a write that did not land, leaves the verdict unrecorded, so the next boot tries again. Without a `sys_migration` table (no `PlatformObjectsPlugin` in the composition) the pass still runs on every boot, and renames nothing it already named.
12+
- **Nothing to migrate.** No configuration is needed.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **54 of the 232 sites are spelled that way**. A
125+
forwarding shim cannot, and **54 of the 234 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **232** |
190+
| 175 write call sites | quoted in the merged changeset | **234** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 102 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 234** decidable, **80** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 103 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
122 of 232 (53%) as decidably elevated, with 102 more whose elevation is a
210+
123 of 234 (53%) as decidably elevated, with 103 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 234`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **31 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **232** |
226+
| write call sites on the application surface | **234** |
227227
| …whose object name is statically decidable | 154 |
228-
| …whose object name is chosen at run time | 78 |
228+
| …whose object name is chosen at run time | 80 |
229229
| …against an object with tenancy ENABLED | 153 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 170 |
231+
| threading a tenant context | 172 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 54 |
235235
| …of those, against a decidably tenancy-enabled object | 31 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 122 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 123 |
237237
| threading a context that is decidably NOT elevated | 0 |
238-
| threading a context whose elevation is a run-time fact | 102 |
238+
| threading a context whose elevation is a run-time fact | 103 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 184 |
242+
| receiver carried a readable engine type | 186 |
243243
| receiver erased, placed by the object NAME | 28 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247247
| object name spelled inline | 104 |
248248
| object name spelled through a `const` | 50 |
249249
| object name is an `object: string` parameter | 19 |
250-
| object name is some other run-time expression | 59 |
250+
| object name is some other run-time expression | 61 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

@@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-07 at `a93579f45`.
300+
Measured on 2026-10-08 at `7f9500afd`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 612 |
305-
| engine-shaped types recognised | 69 |
304+
| tracked non-test sources scanned | 615 |
305+
| engine-shaped types recognised | 70 |
306306
| declared objects in the registry | 116 |
307307
| same-named calls subtracted as non-engine | 160 |
308308

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 232 |
36+
| Write call sites | 234 |
3737
| Object name statically decidable | 154 |
38-
| Object name chosen at run time | 78 |
38+
| Object name chosen at run time | 80 |
3939
| Against a tenancy-enabled object | 153 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 170 |
41+
| Threading a tenant context | 172 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 54 |
4545
| …and decidably tenancy-enabled | 31 |
46-
| Threading a decidably elevated context | 122 |
46+
| Threading a decidably elevated context | 123 |
4747
| Threading a decidably non-elevated context | 0 |
48-
| Threading a context of undecidable elevation | 102 |
48+
| Threading a context of undecidable elevation | 103 |
4949

5050
## Subtractions the census could NOT defend — enforced
5151

@@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-07 at `a93579f45`.
93+
Measured on 2026-10-08 at `7f9500afd`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 612 |
98-
| engine-shaped types recognised | 69 |
97+
| tracked non-test sources scanned | 615 |
98+
| engine-shaped types recognised | 70 |
9999
| declared objects in the registry | 116 |
100100
| same-named calls subtracted as non-engine | 160 |
101101

@@ -167,6 +167,8 @@ Measured on 2026-10-07 at `a93579f45`.
167167
| `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `update` | `object` | undecidable | elevated | 1 |
168168
| `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
169169
| `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 |
170+
| `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
171+
| `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 |
170172
| `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 |
171173
| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 |
172174
| `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts` | `delete` | `sys_metadata` | enabled | context, elevation undecidable | 1 |

0 commit comments

Comments
 (0)