Skip to content

feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) - #22143

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15196-s4b-grant-name-backfill
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15196-s4b-grant-name-backfill

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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 (feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member #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

claude added 9 commits October 8, 2026 00:32
…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>
…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>
… 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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 35 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json aa9447c70bd82fc062ed52670e91f2604f61d8f0.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: logError (9 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 18 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json aa9447c70bd82fc062ed52670e91f2604f61d8f0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b8567d09c9e933806adb78aa306be6c151e361d5 — the merge of head 0306ca272adca77fcd99902ecb3f1d3a4b5d5ab0 into base aa9447c70bd82fc062ed52670e91f2604f61d8f0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs aa9447c70bd82fc062ed52670e91f2604f61d8f0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…b-grant-name-backfill

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 05:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 05:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 7d7943d Oct 8, 2026
43 of 44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15196-s4b-grant-name-backfill branch October 8, 2026 06:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants