Skip to content

feat(core,plugin-security,plugin-auth)!: the authorization resolver reads a grant's permission set by name (ADR-0131 D4, C2 stage S5a) - #22495

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-15196-s5a-resolver-grant-name
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-15196-s5a-resolver-grant-name

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15196
Clause-②: no (narrowing)

C2 stage S5a (domain:engine): the authorization resolver reads which permission set a user grant holds from the grant's name, sys_user_permission_set.permission_set (ADR-0131 D4). The card stays open for S5c, S8, S9 and S10.

What changes

The resolver (packages/core/src/security/resolve-authz-context.ts, resolveUserAuthzGrants §6 and §6b):

  • A user grant's set row is found by its name: the grant's own organization's row, otherwise the organization-less row (readGrantSetRowsByName). The grant's permission_set_id is no longer read here.
  • A grant that names nothing, or whose name resolves only to another organization's set, confers nothing.
  • Platform-admin anchor. An unscoped grant reads the organization-less admin_full_access row only. Deactivation is still read from the row. Under walled postures the anchor stays retired.
  • Position-bound sets are still reached through the junction's id. That relation belongs to C3 (KEPT-C3, untouched).
  • The by-name read is issued beside the sys_position read, so it adds no sequential leg.
  • ADMIN_STANDING_SURFACE declares the columns the resolver now reads: sys_user_permission_set.permission_set in place of the id, and sys_permission_set.organization_id.

Prerequisite 2: the S4a name hook applies the S4b rule at write time (grant-permission-set-name.ts).

  • Measured first, at the write door: a tenant-less system writer inserting an organization-less grant whose id named another organization's set stored the name qa_b_only, that set's name.
  • Now a name is taken only from a set row of the grant's own organization, or from an organization-less row.
  • Another organization's set is never named after:
    • a supplied name is refused for every caller, system included, with the hook's existing 400 VALIDATION_FAILED and invalid_value at permission_set;
    • no name is stamped;
    • an update that re-points the grant at such a set, or moves the grant to an organization its set does not belong to, clears the name.

Prerequisite 1: the upgrade-boot window, accounted for in S5a.

  • The backfill runs at kernel:bootstrapped. Both kernels await every kernel:bootstrapped handler before kernel:listening, where HTTP servers open their socket. So no request reaches the resolver while the grants the backfill can name are still unnamed.
  • Only boot code running at kernel:ready sees them unnamed. Pinned on a real LiteKernel boot.
  • Route C (moving the backfill ahead of the kernel:ready readers) was not taken. S4b measured that a kernel:ready placement misses late-registered sets (its ablation A6). Under a by-name resolver those grants would then confer nothing for the whole process lifetime.
  • security-plugin.ts is not touched.

The S4b carrier: a grant whose id names another organization's set confers nothing. This follows the seat's ruling: a set resolves by name, in its own organization's row and otherwise the organization-less row.

Break-glass guard (plugin-auth, last-admin-guard.ts). The resolver now reads sys_permission_set.organization_id, so the guard's correspondence gate asks for a disposition. The guard now:

  • treats the column as a standing column;
  • counts only the organization-less admin_full_access row as the anchor, matching the resolver;
  • refuses moving that row into an organization when no administrator would remain.

Read sites (census rows 31–35, located by symbol on 35ef501e1)

Site Leg Moved or kept Why
sys_user_position read (Leg 1) assignment by position name kept already a name (ASSIGN)
sys_user_permission_set read (Leg 1) which set a grant holds moved: permission_set_id → permission_set REWRITE-C2
same grant organization, validity window kept unchanged grant rule
sys_position read (§6a) position existence and active kept KEPT-C3 id bridge, OPEN-ACTIVE; REG is S8a
sys_position_permission_set read (§6a) binding kept KEPT-C3
sys_permission_set read (§6b) user-grant leg moved: by id, installation-wide → by name, own organization's row else the organization-less one REWRITE-C2 and the S4b carrier
same position-bound leg kept, by the junction's id KEPT-C3 (id→name bridge)
same active, capability body kept on the row Q2 = A (OPEN-ACTIVE); REG(body) is S8a
platform-admin anchor (§6b) unscoped admin_full_access grant moved: by id → by name, on the organization-less row it read a grant by id (unscopedUserPsIds)
platform-admin.ts (§6b-config) declared owner email untouched reads no grant

@objectstack/core cannot import plugin-security. It keeps its own internal copy of the rule (grantSetNameOf, not exported), the shape S5b set ("one copy per package, internal"). No new dependency edge, no cycle.

Pins, each reverse-verified (one-time runs, quoted in the report)

  • (a) Goldens per principal, base-recorded. resolve-authz-grant-set-by-name.golden.test.ts covers the platform admin, the organization admin, a member and an agent, in single, group and isolated. Each records the envelope inside the organization and outside it, plus hasPlatformAdminStanding.
    • Recorded on 61765bfb: goldens only, production code at the base.
    • Unchanged with the change.
    • S5b's and S4a's goldens are unchanged too.
  • (b) The census ablation. One grant's name was pointed at another set, with the name hooks unbound, via ablation-replace.
    • With the change, each leg turns its golden red in every posture that holds that grant: member 3/3, agent 3/3, organization admin single 1/3 and walled 2/3, platform admin single 1/3 (no grant row under a wall).
    • On the base resolver, all five re-points at once leave the goldens green, 3/3.
  • (c) A grant whose id names another organization's set confers nothing. Pinned in core (recording double) and in plugin-security (SQL driver, real hooks). Red on the base resolver.
  • (d) An unnamed grant confers nothing, and its id still restricts. Pinned in core and in plugin-security: the organization-admin reconcile still revokes an unnamed grant through its id. Red on the base resolver.
  • (e) The upgrade boot. On a real LiteKernel boot, a grant stored before the name column is unnamed at kernel:ready and confers at kernel:listening.
    • The kernel:ready leg is red on the base resolver.
    • Unwiring the backfill turns the kernel:listening leg red.
  • (f) Write-time rule in the hook. Four pins: stored unnamed, supplied name refused, a re-point clears the name, an organization move clears the name. All four are red with the hook at the base; the control stays green.
  • Guard. Moving the organization-less admin_full_access into an organization is refused. Red with the guard at the base; the two controls stay green.

Fixture triage

The resolver no longer reads the id, so test grants written without the name hooks now carry the name every platform writer stores beside the id. Hand-written engine doubles now read an absent column as NULL, as SQL does, and answer a sys_permission_set read by name.

  • The core batch-equivalence query goldens move for the sys_permission_set entries of the three fixtures that hold a user grant. The move is written down in the test file, as its header requires.
  • Every other recorded read, and every envelope, is unchanged.
  • The leg count drops from 3 to 2 for two fixtures.
  • S4b's "no principal's grants change" pin now states the new truth: before the backfill, unnamed grants confer nothing; after it, the golden holds.

Cross-lane paths (domain:services, beyond the claim's file surface)

  • packages/plugins/plugin-security/src/grant-permission-set-name.ts, as claimed.
  • packages/plugins/plugin-auth/src/last-admin-guard.ts, not in the claim. This is the guard's half of the surface this stage changed: its correspondence gate is red without it.
  • Tests in plugin-security: grant-permission-set-name.test.ts, grant-permission-set-name-backfill.test.ts, resolve-authz-grant-set-by-name.test.ts and resolve-authz-grant-set-by-name.golden.test.ts (both new), and the fixtures of explain-enforce-parity, explain-engine, explain-positions-name-authority, explain-removed-member-principal, orgless-position-name-fold and security-plugin.
  • Tests in plugin-auth: last-admin-guard.test.ts and the fixtures of 10 test files.
  • Test fixtures only: rest (11 files), runtime (11), plugin-hono-server (5), cloud-connection (5), plugin-sharing (1), plugin-approvals (2), service-automation (1), service-datasource (2), service-settings (1).
  • No security-plugin.ts, no packages/spec, no id-column change, no deletion, no driver arm, no junction read.

Verification at 2e2d3bec

origin/main 35ef501e1 was merged in clean.

  • Suites:
    • core: 2278 passed, typecheck 0;
    • plugin-security: 3958 passed, 45 skipped, typecheck 0;
    • plugin-auth: 2731 passed, 10 skipped, typecheck 0.
  • Grant-fixture test files in the consumer packages: rest 240, runtime 247, plugin-hono-server 43, client 50, cloud-connection 514 (full), service-datasource 763 (full), plugin-sharing 35, plugin-approvals 24, service-automation 20, service-settings 18, mcp 21, organizations 38, objectql 173, lint 147, triggers 23, verify 2. All passed.
  • Dogfood: 40 files that read or write grant rows; 427 passed, 1 skipped.
  • Gates: dispatch-gates --commands derived 71; all 71 exit 0. --ran answers "71 derived, 71 run, 0 NOT-MEASURED (a DERIVED zero)". Two needed a prerequisite before they ran: a fixture commit fetched for check-plugin-teardown-shape --self-test, and seven unrelated packages built for check:dual-build-cjs-loads.
  • check:durability-log-level: exit 0.
  • Lint (narrowed): eslint --no-inline-config --format json over the 73 changed TS files: 73 linted, 0 errors, 0 warnings, none ignored. eslint.config.mjs enables no type-aware linting, so untouched files are invariant.
  • Integration layer: the cli integration test (a write through the real data door, stamped by the hook) and http-conformance are left to CI.

Acceptance notes

  • Unnamed grants on a real upgrade. The backfill leaves some grants unnamed for good: an id with no set row, an id on another organization's set, or a name the catalog does not resolve. After this stage those grants confer nothing. That is the ruled fail-closed answer, and they are listed in the backfill's boot report.
    • A deployment holding row-only permission sets from before the set write-through would see their grants stay unnamed (catalog-unresolved) and stop conferring. This is inference from source; no such deployment was measured.
  • The backfill's failure line in security-plugin.ts still says unnamed grants "keep no name until a later boot runs it". With this stage they also confer nothing until then.
    • Carrier: the next stage that holds security-plugin.ts. Not edited here.
  • Three copies of the by-name rule now exist: core, plugin-security and plugin-auth, each internal. Core is upstream of both plugins, so one exported copy in core is possible. Recorded as an open question, not built.

Generated by Claude Code

claude added 11 commits October 9, 2026 12:31
…s per principal on the base

Recorded with production code at the base: platform administrator,
organization administrator, member and agent in single, group and
isolated, the envelope inside the organization and outside it, and
platform standing.

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…n set by name

resolveUserAuthzGrants §6 keys a sys_user_permission_set grant on its
permission_set name (ADR-0131 D4). The set row is the grant's own
organization's row, else the organization-less one; a grant that names
nothing, or whose name resolves only to another organization's set,
confers nothing. The platform-admin anchor reads the organization-less
admin_full_access an unscoped grant names. Position-bound sets are still
reached through the junction's id (ADR-0131 C3).

The admin-standing surface declares the columns now read. The core test
fixtures' grants carry the name the platform writers store; the
batch-equivalence query goldens move for the sys_permission_set reads only.

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…zations; the guard reads the anchor the resolver reads

The grant name hook applies the grant rule at write time: the set row the
id points at must be the grant's own organization's or organization-less.
Another organization's set (readable by a tenant-less system writer) is
never named after: a supplied name is refused from every caller, no name
is stamped, and a re-point or an organization move clears the name.

The break-glass guard counts the organization-less admin_full_access row
as the anchor, as the resolver now does, and judges writes to its
organization_id.

Pins for the resolver's by-name read in core.

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…ite-time organization rule, the upgrade boot; changeset

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…ion is a standing write

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…s as the platform writers do

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
… do; the memory doubles read an absent column as NULL

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…'s name; doubles answer the by-name read

The authorization resolver reads a user grant's set by name, so the
fixtures' grant rows carry the name every platform writer stores beside
the id, and the hand-written engine doubles read an absent column as
NULL and answer a sys_permission_set read by name.

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
…t fixtures carry their set's name; doubles answer the by-name read

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
… before the stored name

Claude-Session: https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cloud-connection, @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security, touching 31 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

27 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 35ef501e1301a2e992f4f785a9ef469ce79fc086.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 1 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 — 43 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 35ef501e1301a2e992f4f785a9ef469ce79fc086 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8952586cb17ebc454483a8bc495746fdd0330c64 — the merge of head 2e2d3bec0d43d273647138c1a468b1c9acaa15de into base 35ef501e1301a2e992f4f785a9ef469ce79fc086, 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 8952586cb17ebc454483a8bc495746fdd0330c64 && git checkout 8952586cb17ebc454483a8bc495746fdd0330c64
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 35ef501e1301a2e992f4f785a9ef469ce79fc086 2e2d3bec0d43d273647138c1a468b1c9acaa15de && git checkout -B drift-repro 35ef501e1301a2e992f4f785a9ef469ce79fc086 && git merge --no-ff 2e2d3bec0d43d273647138c1a468b1c9acaa15de

node scripts/docs-audit/affected-docs.mjs --json 35ef501e1301a2e992f4f785a9ef469ce79fc086

⚠️ 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 35ef501e1301a2e992f4f785a9ef469ce79fc086 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 15:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 15:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 2e10c9a Oct 9, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15196-s5a-resolver-grant-name branch October 9, 2026 15:41
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