Repository navigation
feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) - #22143
Conversation
…ts boot wiring (ADR-0131 C2 S4b) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…l (ADR-0131 C2 S4b) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ger as it stands Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…name backfill Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ady provider's set on the same boot, on a real kernel The module doc records the measured write path (the S4a hooks judge the backfill's system write but stamp only a write carrying the id), the unwalled set-row read the resolver makes today, and why the pass runs without a ledger. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…b-grant-name-backfill
… writes Regenerated with tenant-audit-census --write; the page's prose figures follow (233 to 235 write call sites, 78 to 80 undecidable, 123 to 124 decidably elevated, 102 to 103 elevation undecidable). Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ery options Ten read sites erased their options bag to any; the query-options erasure ratchet counted them (test surface 236 to 246). Typed against the engine's own find / findOne signature now, and the count is back at 236. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b8567d09c9e933806adb78aa306be6c151e361d5 && git checkout b8567d09c9e933806adb78aa306be6c151e361d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa9447c70bd82fc062ed52670e91f2604f61d8f0 0306ca272adca77fcd99902ecb3f1d3a4b5d5ab0 && git checkout -B drift-repro aa9447c70bd82fc062ed52670e91f2604f61d8f0 && git merge --no-ff 0306ca272adca77fcd99902ecb3f1d3a4b5d5ab0
node scripts/docs-audit/affected-docs.mjs --json aa9447c70bd82fc062ed52670e91f2604f61d8f0
|
…b-grant-name-backfill # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Part of #15196
Clause-②: no
What this does. Grants stored in
sys_user_permission_setbefore thepermission_setname column existed carryNULLthere. On boot,@objectstack/plugin-securitynow fills that column once on each such grant. The value is thenameof thesys_permission_setrow that the grant's ownpermission_set_idnames, read inside the grant's own organization and checked through the security catalog read before it is written. A grant this pass cannot name keepsNULLand is reported in the boot log. The pass records its verdict once insys_migration. Clause-② isno: 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
6049507458on the card). Not in this PR:packages/spec, nothing inplugin-auth;What changes
grant-permission-set-name-backfill.ts(new,plugin-security). For every grant whose name isNULLor blank (the whole set is read, ordered by id, before anything is written):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.createSecurityCatalogReaderfrom@objectstack/core). A name the catalog does not resolve is not written.warn;warn;error, with the remedy;error;AuthzStoreUnavailableError): the pass stops and says which read failed. It never reads an outage as "no such set".plugin-auth'smembership-backfill-ledger.ts, which this PR reads but does not edit:adr-0131-grant-permission-set-name-backfill;verified_at: null, since nothing gates on it;blocking: 0;detailsholds the counts.NULLwith the name the row's own id already names, so a second run writes nothing new. The cost is one scan per boot, and awarnthat says so.SecurityPlugin.start, beside the platform bootstrap (near:4816; disjoint from stage S2's manifest edit near:1448). It runs atkernel:bootstrapped, inside atrythat only warns, so the boot never fails on it.scripts/adr-anchors/…grant-permission-set-name-backfill.ts.jsonanchors the module to ADR-0131.content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdwere regenerated withtenant-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 ofmainataa9447c70b, whose own regeneration had moved the base from 233 to 232.The write path, and why (measured)
Measured on a real
ObjectQLengine overSqlDriverwith the realSecurityPluginstarted, undersingleandisolated. The S4a hooks run on the backfill's system write. Results:{ id, permission_set: OTHER_SET_NAME }VALIDATION_FAILED{ id, permission_set: AGREEING_NAME }(this PR's write){ id, permission_set_id: SAME_ID }, no name{ id, OTHER_COLUMN: VALUE }NULLSo 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:
permission_set_id. So nothing keyed on that column re-judges the grant:plugin-auth's last-administrator guard lists it inGRANT_STANDING_KEYS.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:
resolveUserAuthzGrantswas 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 insingle,groupandisolated:NULLNULLInside 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 everykernel:readyhandler has settled, onObjectKerneland onLiteKernel. Code-declared and environment catalog items are in the registry bykernel:ready. A realLiteKernelboot is pinned:kernel:readyhandler registers a permission set afterSecurityPlugin's own handler;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:singleandisolated.error, and the verdict is not recorded.error, and no record.Ablations. All legs ran at
06642523e1throughscripts/ablation-replace.mjsin wrap mode. Each restore was proven by blob equal toHEAD, with an emptygit diff HEAD. The subject is imported fromsrcby a relative path, so nodist/leg applies.report, never guess): silent catalog, unresolved-yetonce, and remembered): second pass returnsraninstead ofalready-run'ps_gone'to benullvalid_untilin the pastkernel:readyVerification
Head
7f9500afd5. That ismainat8fc50b7647merged in, plus this PR.plugin-securitytypecheck is green, test layer included: 0 files, 0 errors, 0 debt.plugin-securityvitest 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 local78 files, 2192 passed. The closure was rebuilt first.rls-multitenant's ownskipIf). 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-seedingandrls-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.dispatch-gates --commandsderived 104 at this head, and all 104 exited 0.dispatch-gates --ran: 104 run, 0 NOT-MEASURED, 0 UNRUN.check:tenant-audit-census(regenerated, above) andcheck:query-options-erasure. For the second, ten test reads had erased their options toany; they are now typed, and the test surface is back to 236.PREREQUISITE NOT METrefusals (exit 3) were re-run after building their packages, and both exit 0.eslint --no-inline-config --format jsonover the 3 changed TS files: 3 linted, 0 errors, 0 warnings, none ignored. The config sets noparserOptions.projectand no typed rule, so untouched files' verdicts are invariant. The fullpnpm lintis CI's.0306ca272a(mainataa9447c70bmerged; no rebase, no force-push). The two census files conflicted with the regeneration that landed onmain. They were resolved by regenerating withtenant-audit-census --writefrommain'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-censusandcheck-regen-pendingall exit 0; no os-regen path is touched and none is pending.plugin-securitytypecheck is green after a closure rebuild.dispatch-gates --commandsderived the same 104 commands. All 104 exit 0 after rebuilding the packages the dist-reading gates read, and--ranreports 104 run, 0 NOT-MEASURED, 0 UNRUN.aa9447c70b: six commits. One of them (feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member #22173) editssecurity-plugin.tsin other regions, andgit merge-treeagainst it is clean. The boot refusal it adds asks forsys_userandsys_member, which the pins' engine registers. The joint build is CI's and the queue's.Acceptance notes
resolve-authz-context.ts§6).NULLnames, not trust this ledger row. Carrier: C8.persistGrantNameBackfillRecordis a new durability seam. It logs aterrorand is pinned, but it is not inDURABILITY_CRITICAL_CALLEES, because that edit is outside this stage's surface. Carrier: none.Generated by Claude Code