Skip to content

feat(plugin-security,plugin-auth): the grant readers read the permission-set name column (ADR-0131 D4, C2 stage S5b) - #22352

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

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15196-s5b-grant-readers-by-name

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15196
Clause-②: no

ADR-0131 D4, C2 stage S5b. The domain:services grant readers key on sys_user_permission_set.permission_set, the grant's permission set by name, instead of permission_set_id. S4a dual-writes that column and S4b backfills it. Untouched: the resolver in @objectstack/core (S5a), verify (S5c), security-plugin.ts, packages/spec and the id column (C8). Nobody's resolved permissions change. The goldens below hold that per principal, and the ablation shows the readers really read the name.

The census's REWRITE-C2 read sites, re-read on the landed shape

The base is 28bff18d0c, which is origin/main at worktree creation; fe98cc63..28bff18d0c touches only packages/cli. The census was taken at e67ba80049.

File · function Census → base Read by id at base Read after this PR
explain-engine.ts · collectGrantProvenance (grants) :510 → :510 maps permission_set_id maps permission_set
explain-engine.ts · collectGrantProvenance (set rows) :522 → :522 set rows by id set rows by the grant's name: own organization's row, else the organization-less one (readGrantSetRows)
delegated-admin-gate.ts · assertDirectGrantWrite :790 → :825 pre-image's id leads to the set row pre-image's name (delete, or an update that keeps the set); an insert or re-pointing update still reads the id it writes
delegated-admin-gate.ts · loadSetRowById :1238 → :1270 the set row by id kept for the written id only; the pre-image uses loadSetRowForGrant
bootstrap-platform-admin.ts · findPlatformAdminGrantHolder legs A and B :637 → :638, :653 → :654 grants by the admin row's id grants naming admin_full_access; leg C (new) is described under "A grant whose name is empty"
bootstrap-platform-admin.ts · findExistingPlatformAdmin :706 → :706 set row by name, to get the id unchanged (existence precondition; the id feeds leg C only)
bootstrap-platform-admin.ts · bootstrapPlatformAdmin promote :1133 → :1170 writer unchanged (writes both columns since S4a)
auto-org-admin-grant.ts · resolvePermissionSetId :452 → :452 set row by name, to get the id unchanged (the id a new grant is written with)
auto-org-admin-grant.ts · resolvePermissionSetIdsForName :560 → :560 every copy's id unchanged; feeds the unnamed id leg only
auto-org-admin-grant.ts · reconcileOrgAdminGrant superseded / existing / revoke reads :721, :763, :818 → :721, :763, :821 grants by id grants by name, plus unnamed grants by id (grantsHoldingName)
auto-org-admin-grant.ts · backfillOrgAdminGrants orphan sweep :935 → :938 grants by id by name, plus unnamed grants by id
auth-manager.ts · findPermissionSetRows :5007 → :5006 set rows by name unchanged (the writer's id, and active)
auth-manager.ts · settleSelfRegistrationGrant existence :5274 → :5273 (user, id) (user, name)
ensure-default-organization.ts · ensureDefaultOrganization :354, :359 → :464, :471 set row by name, then grants by id set row (existence) unchanged; grants naming admin_full_access
last-admin-guard.ts · resolveAdminUserIds :945, :961 → same set rows by name, then grants by id grants naming the set, when every row bearing the name is in effect
last-admin-guard.ts · refuseIfEmptiedRatherThanFresh :1137, :1151, :1180 → same known ids, held ids, $nin known ids known names, held by name, $nin known names
last-admin-guard.ts · grant and set hooks :1554, :1560, :1581, :1617 → same via the enumeration via the enumeration; permission_set joins GRANT_STANDING_KEYS

The census predates no other by-id grant read in these seven files. The other by-id reads there (delegated-admin-gate.ts binding write and setsBoundToPosition) are the position-binding junction, which C3 keeps.

A grant whose name is empty

A grant written before the column existed has no name until S4b's backfill names it. The backfill runs at kernel:bootstrapped. The bootstrap, the organization-admin backfill and the default-organization bind all run at kernel:ready, earlier (security-plugin.ts near :4992, :5020, :5185). On an upgraded deployment's first boot, then, every grant is still unnamed when those readers run. S4b also leaves some grants unnamed for good: dangling ids, ids on another organization's row, and names the catalog does not resolve. Each reader takes the fail-closed side:

Reader Answer for an unnamed grant
explain provenance reports nothing; the grant names no set
delegated-admin gate (pre-image) refuses the delegate (names no permission set); a tenant admin is not judged here
platform-admin bootstrap leg C reaches an unscoped human grant on the admin row through its id and withholds the promotion (reason: 'admin_grant_unnamed', warn). It names no adminUserId, so findExistingPlatformAdmin answers undefined
organization-admin reconcile revoke reach and duplicate check still find it through its id; nothing is granted through it
last-admin guard counted as no administrator; an unscoped, in-window one is evidence in the zero-administrator path, so the write is refused rather than the bootstrap window opening
default-organization bootstrap no_admin; no decision is recorded, so a later trigger binds
self-registration n/a: a new user's grants are written with names

The id leg is used only to restrict (revoke, refuse a promotion, block a duplicate insert), never to confer. It goes when C8 counts the unnamed grants and drops the column.

Goldens, recorded on the base tree, and the ablation

  • plugin-security/src/grant-readers-by-name.golden.test.ts covers explain, platform standing and organization-admin standing per principal (platform admin, organization admin, member, agent) in single, group and isolated. plugin-auth/src/grant-readers-by-name.golden.test.ts covers the last-admin guard's verdicts and the default-organization bind.
  • Recorded on 856ed8f1ad (goldens only, production code at base), green there, and green with the change.
  • Ablation: one grant's name column pointed at another set, with the name hooks unbound.
    • Base 856ed8f1ad: all four mutations together leave both suites green (3/3 and 3/3). Base readers read the id.
    • With this change, each leg is red:
      • member's deactivated-set grant: 3/3 red (droppedGrants loses the deactivated entry);
      • platform admin's grant: 1/3 red (the single posture: the re-run promotes, already_have_admin is lost);
      • organization admin's grant: 3/3 red (the demotion no longer revokes it);
      • plugin-auth, platform admin's grant: 1/3 red (no_admin, and the guard verdicts change).
    • Every leg ran through scripts/ablation-replace.mjs in wrap mode, and each restore was proven: blob equals HEAD and git diff HEAD is empty.

Durability gate

persistGrantNameBackfillRecord joins DURABILITY_CRITICAL_CALLEES (scripts/check-durability-degradation-log-level.mjs). The gate goes from 41 to 42 seams, all loud, and its self-test passes (63 + 57 cases). Ablation: the backfill's verdict-row catch was demoted to warn, and the gate went red at grant-permission-set-name-backfill.ts:550. Restore proven by blob.

Verification

All at head fff0b0adb7 (main 5ff7cbe364 merged in as e6879a88d1) unless named.

  • @objectstack/plugin-security: vitest 185 files, 3880 passed, 45 skipped. typecheck exit 0; test layer 0 files / 0 errors / 0 debt.
  • @objectstack/plugin-auth: vitest 132 files, 2672 passed, 10 skipped. typecheck exit 0; test-layer debt unchanged at 10 files / 94 errors / 23 signatures.
  • Dogfood: 46 files that write or read grant rows, or that exercise these readers (closure built at e6879a88d1). 45 passed and 1 skipped (rls-multitenant's own skipIf); 436 tests passed, 3 skipped.
  • New pins: grant-readers-unnamed-grant.test.ts in both packages (7 + 7), and the pre-image block in delegated-admin-gate.test.ts (5).
  • Edited gate scripts:
    • pnpm check:durability-log-level (self-test included) exit 0: 42 seams.
    • measure-durability-swallow-family.mjs: its gate-vocabulary copy gained the same name, because check:swallow-census-controls went red on the drift. --self-test and --self-test=gated both exit 0.
    • The metadata-protocol test that reads the gate script: 44/44.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 91 commands at fff0b0adb7, and every one exited 0, captured before any pipe. --ran: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. pnpm check:error-status-conformance is in that set, and it also exited 0 when run by hand.
  • Lint, narrowed: eslint --no-inline-config --format json over the 30 changed TS/MJS files. 30 linted, 0 errors, 0 warnings, none ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so untouched files' verdicts cannot move. The full pnpm lint is CI's.
  • origin/main is 3 commits ahead of the head. The only overlap is a comment-only hunk in auto-org-admin-grant.ts (PR feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) #22331), far from this diff.

Acceptance notes

  • S5a/S5b window. These readers answer by name, while the resolver still answers by id until S5a. The two diverge only on a grant whose columns disagree: an unnamed grant, or a named grant whose id no longer resolves. Each reader rounds toward refusing or conferring nothing, so none widens; some narrow in that window (above).
  • Organization-admin reconcile. A pair already holding the organization-admin set by name, through another organization's copy, is not handed a second grant. The dedupe still deletes only exact same-id duplicates, never a grant against another copy.
  • Guard and the name column. The guard now judges a write to permission_set. Clearing the last administrator's grant name is refused (pinned). With the S4a hooks bound, a cleared name was already written back.
  • S4a and S4b changesets. Their text says "no reader uses the column yet". That no longer holds for these readers. This PR's changesets say so rather than editing the earlier ones.
  • Delegated-admin gate comment. callerOrganizationId's docblock now states the landed single behaviour (the Default Organization), per the seat's note. Comment only.

Generated by Claude Code

claude added 10 commits October 8, 2026 17:40
…re the readers move to the name

Grant-equivalence goldens for the ADR-0131 D4 grant readers in the two
plugins, per principal (platform administrator, organization administrator,
member, agent) in the single, group and isolated postures. Recorded on the
tree before any reader changes its key, so the next commits must hold them.

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

ADR-0131 D4 stage: the domain:services grant readers read which permission
set a sys_user_permission_set grant holds from its permission_set name
column instead of permission_set_id. A grant whose name is empty grants
nothing through a by-name reader; readers that restrict (the org-admin
revoke reach, the promotion guard, the duplicate check before a grant
insert) still reach such a grant through its id until the id column drops.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ing, and fixture names

The by-name readers' fail-closed treatment of a grant whose name column is
empty: explain reports nothing, the bootstrap withholds the promotion and
names no holder, the reconcile still revokes through the id, the delegated
gate refuses the pre-image. Grant fixtures that bypass the engine's name
hooks now carry the name every platform writer stores.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ate's critical callees

The one-time grant-name backfill's sys_migration verdict write is a
durability seam: a lost row reads clean while the pass stops being one-time.
The gate now judges its catch like the membership ledger's.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… unnamed-grant guard pins

Hand-declared grant objects in the guard suites declare the permission_set
column and their seeded grants carry the name every platform writer
stores. The unnamed-grant pins bypass the guard to clear a name, which the
guard itself refuses as a revocation of the sole administrator's standing.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… under single is the Default Organization

Comment only. Since ADR-0131 C1 every session on a stock single boot
carries the Default Organization as its active organization, so the
docblock no longer says the caller organization is undefined there.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…fillRecord from the gate vocabulary

The census keeps a deliberate copy of the durability gate's critical
callees and its gated self-test reds when the two drift; the gate gained
the grant-name backfill's ledger write, so the copy does too.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…me the platform stores

The fixture arrived from main with an id-only grant; the default-organization
bootstrap now finds the platform administrator by the grant's set name.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl 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 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 29 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via permission_set_id (literal, a string literal in GRANT_SET_ID_FIELD), sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/automation/approvals.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/concepts/metadata-lifecycle.mdx (via sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/data-modeling/objects.mdx (via SysUserPermissionSet (symbol, a top-level const object), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/deployment/environment-variables.mdx (via sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/deployment/self-hosting.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/deployment/validating-metadata.mdx (via permission_set (literal, a string literal in GRANT_SET_NAME_FIELD; a string literal in GRANT_STANDING_KEYS))
  • content/docs/permissions/authorization.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class), sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/permissions/explain.mdx (via permission_set (literal, a string literal in GRANT_SET_NAME_FIELD; a string literal in GRANT_STANDING_KEYS))
  • content/docs/permissions/permission-sets.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), permission_set_id (literal, a string literal in GRANT_SET_ID_FIELD), sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/permissions/permissions-matrix.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/permissions/sharing-rules.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/permissions/system-context.mdx (via permission_set (literal, a string literal in GRANT_SET_NAME_FIELD; a string literal in GRANT_STANDING_KEYS), permission_set_id (literal, a string literal in GRANT_SET_ID_FIELD), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/ui/audience-based-interfaces.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))

⛔ 13 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/releases/v12.mdx (via sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/releases/v13.mdx (via DelegatedAdminGate (symbol, a top-level class), sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/releases/v14.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/releases/v15.mdx (via sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/releases/v16.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/releases/v17/17-0.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/releases/v17/17-1.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_permission_set (literal, a string literal in collectGrantProvenance), sys_user_permission_set (literal, a string literal in backfillOrgAdminGrants; a string literal in findPlatformAdminGrantHolder; a string literal in grantsHoldingName; a string literal in reconcileOrgAdminGrant))
  • content/docs/releases/v17/17-2.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/releases/v17/17-3.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization))
  • content/docs/releases/v17/17-5.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/releases/v17/17-6.mdx (via admin_full_access (literal, a string literal in ensureDefaultOrganization), sys_permission_set (literal, a string literal in collectGrantProvenance))
  • content/docs/releases/v17/17-7.mdx (via ensureDefaultOrganization (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 24 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 c6fc938ce303ccf48f47ca5eff47717b7f9b4e20 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c6fc938ce303ccf48f47ca5eff47717b7f9b4e20

⚠️ 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 c6fc938ce303ccf48f47ca5eff47717b7f9b4e20 → 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 8, 2026 21:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 21:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 19bb55e Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15196-s5b-grant-readers-by-name branch October 8, 2026 21:49
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… behind its cookie (objectstack-ai#22367)

Part of objectstack-ai#22258
Clause-②: yes (widening)
Release: the domain:services half of this card stays open, carried by
domain:services. Nine in-process readers there (plugin-auth,
plugin-webhooks, plugin-sharing, service-storage, service-settings,
service-datasource; table H5 below) still renew a cookie session without
re-issuing its cookie.

## What this changes

An in-process `auth.api.getSession` read renews a session past
better-auth's `updateAge` and stages the renewed cookie on a response
the door never sends. The browser's cookie then dies before its session:
a split session, a dead cookie beside a live bearer.

One rule now covers every in-process reader in this lane. It lives in
one helper, `inProcessSessionReadInput(headers)` in
`@objectstack/types`, and is decided by what the request carries:

- **A session cookie** (a browser, including the console, which sends
its cookie beside its bearer): the read passes `query: { disableRefresh:
true }`. The session renews only through `GET /api/v1/auth/get-session`,
which re-issues the cookie, so cookie and session expire together.
- **No session cookie** (a bearer-only client): read exactly as before,
renewal included. No cookie exists to fall behind, and the bearer is the
session token, which renewal does not change.

The rule only ever adds `disableRefresh`. It sets no cookie, forwards
none, and never changes which session a request resolves to.

**Applied at all ten readers in this lane.** The census on `b7e01fbbd`
found six. Four more use the optional-chained spelling
`api?.getSession?.(`, which the `.getSession(` regex did not match:

| package | reader (line on this branch) | in the PM census |
|---|---|---|
| rest | `rest-server.ts:3037` `computeExecCtx` getter | yes |
| rest | `rest-server.ts:3224` auth-gate re-read | yes |
| runtime | `http-dispatcher.ts:1362` `enforceAuthGate` | yes |
| runtime | `http-dispatcher.ts:1442` `enforceProjectMembership` |
**no** |
| runtime | `security/resolve-session-principal.ts:57` (rate limiter,
concrete route mounts) | yes |
| runtime | `security/resolve-execution-context.ts:165` (dispatcher
scope, MCP door) | **no** |
| plugin-hono-server | `current-user-endpoints.ts:412` | yes |
| cloud-connection | `marketplace-install-local-plugin.ts:2624`
`resolveActiveOrgId` | yes |
| cloud-connection | `marketplace-install-local-plugin.ts:2794`
`resolveInstallPrincipal` getter | **no** |
| cloud-connection | `cloud-connection-plugin.ts:209` session bridge |
**no** |

The four extra readers are fixed in place under the bounded in-place-fix
rule: the same defect class, the same one-line call, no other claim on
those files, and the same packages and gates. Their doors split
measurably: the MCP door and `/i18n/locales` go through
`resolve-execution-context` (H1, ablation 2). This adds two files to the
claim's file surface:
`packages/runtime/src/security/resolve-execution-context.ts` and
`packages/cloud-connection/src/cloud-connection-plugin.ts`.

**Where the helper lives, and why there.** The claim suggested a helper
in `packages/runtime`. That cannot serve all ten readers: `rest` cannot
import `runtime` (runtime depends on rest, so it would be a cycle), and
`plugin-hono-server` does not depend on runtime. `@objectstack/types` is
in this lane and is already a dependency of all four reader packages,
which is the same "one home, no new edge" reasoning that file's barrel
records for its other shared rules.

## Why `Part of`, and why `Clause-②: yes`

- **`Part of`.** Triage's done-when reads "No framework door extends a
session without forwarding its cookie". After this PR, every door in
this lane holds (H1). The nine `domain:services` readers still split a
session through public doors (H5), so the card stays open for them.
- **`Clause-②: yes (widening)`, not the claim's `no`.** The claim's
gloss on `no` was "No accepted input, export or published shape
changes", written before the helper's home was chosen. The one shared
helper has to be an export of a published package, so
`@objectstack/types` gains three named exports:
`inProcessSessionReadInput`, `carriesSessionCookie` and the
`InProcessSessionReadInput` type. That is an additive widening of a
published package's public surface, which the `Check Changeset` "WHICH
LEVEL" ruling grades at least `minor`. The changeset is therefore
`minor` for `@objectstack/types` and `patch` for `rest`, `runtime`,
`plugin-hono-server` and `cloud-connection`. Raised with the seat in the
dev report; no accepted input and no wire shape changes.

## H1: reproduced through public doors, then measured on the fix

The probe is a fresh `pnpm dev:crm -- --fresh` stack, run on `b7e01fbbd`
and again on this branch, with better-auth 1.7.3 as pinned. `expiresIn`
(604800 s) is read from the fresh `sys_session` row; the pin reads both
values off the running instance's `sessionConfig`, and `updateAge` is
86400 s. Each row ages the signed-in session in `sys_session` to `now +
expiresIn − updateAge − 60 s`, sends one request, and reads `expires_at`
back.

| door | by | before: Δ `expires_at` · session `Set-Cookie` | after |
|---|---|---|---|
| `GET /api/v1/auth/get-session` (control) | cookie | +86460 s ·
`Max-Age=604800` | +86460 s · `Max-Age=604800` |
| `GET /api/v1/data/sys_user` | cookie | +86460 s · none | **0 · none**
|
| `GET /api/v1/data/sys_user` | bearer | +86460 s · none | +86460 s ·
none |
| `GET /api/v1/auth/me/permissions` | cookie | +86460 s · none | **0 ·
none** |
| `GET /api/v1/auth/me/permissions` | bearer | +86460 s · none | +86460
s · none |
| `GET /api/v1/meta/object` | cookie | +86460 s · none | **0 · none** |
| `GET /api/v1/i18n/locales` | cookie | +86460 s · none | **0 · none** |
| `GET /api/v1/packages` | cookie | +86460 s · none | **0 · none** |
| `GET /api/v1/marketplace/install-local` | cookie | +86460 s · none |
**0 · none** |
| `GET /api/v1/mcp` (answers 406) | cookie | +86460 s · none | **0 ·
none** |

Every door's bearer row is unchanged by the fix (+86460 s, no cookie);
only the first two doors are shown here.

## H3: who holds only a bearer

These clients would lose renewal under a blanket `disableRefresh`.
Measured by reading where each one sends its credential and when it
reaches `get-session`:

- **`@objectstack/client`** sends `Authorization: Bearer` from its
stored token on every request (`fetch`). It calls `get-session` only on
an explicit `auth.me()` or `refreshToken()`. Outside a browser it holds
no cookie jar, so it is bearer-only.
- **The CLI** is bearer-only. It reaches `get-session` only at `os
login` and `os cloud whoami`. Its data commands (`datasource
list-tables`, `validate`, `introspect`, `package publish`, `plugin
publish`) carry a bearer.
- **MCP:** OAuth access tokens are verified separately and are not
better-auth sessions, so they are unaffected. API keys are unaffected
too. A session bearer presented there is bearer-only.
- **objectui console:** NOT bearer-only. Its fetches send the cookie
(`credentials: 'include'`) beside the stored bearer, and it calls
`get-session` on mount and on every re-resolution
(`AuthProvider.loadSession`).

**Before:** every bearer-only data read past `updateAge` renewed, +86460
s on every door above. A blanket `disableRefresh` would end that, and a
CLI or SDK session would die `expiresIn` (7 days) after sign-in however
active. **After:** bearer-only reads still renew, +86460 s on every door
(measured above, and pinned).

## H4: the rule, chosen on that measurement

- **Forward the renewed `Set-Cookie` from every door: measured and not
taken.**
- Only three of the ten readers have a response in hand: the Hono `me/*`
endpoints and two cloud-connection routes, all on a Hono `c`.
- REST's `computeExecCtx(environmentId, req)` has no response object and
is cached per request across many routes.
- The dispatcher is transport-neutral and returns `{ status, body }`. A
forward there would need a header channel through every dispatcher
result and every adapter's `sendResult`.
  - The rate limiter reads before the route.
- A request can pass two or three in-process reads (REST: 2, dispatcher:
up to 3), and only the first renews.
- A forward would still need this same cookie test, so that no cookie is
ever set on a request that sent none.
  - So forwarding cannot be one rule for all ten.
- **Taken: the PM's lean, unchanged.** A cookie request reads with
`disableRefresh`; a bearer-only request reads as before. better-auth
applies the same rule to its own reads that cannot write a cookie (React
Server Components, `dist/integrations/next-js.mjs:62-69`).

## H5: the `domain:services` readers, measured and not edited

Measured on this branch's build, so a remaining renewal belongs to the
services reader and not to a lane reader that ran on the same request.
Line numbers are at `b7e01fbbd`.

| reader | door | by cookie | by bearer |
|---|---|---|---|
| plugin-auth `auth-plugin.ts:2464` | `POST
/api/v1/auth/admin/oauth2/toggle-disabled` | +86460 s · no cookie
(**split**) | +86460 s |
| plugin-auth `auth-plugin.ts:2527` (`gateAdmin`, every admin route
behind it) | `POST /api/v1/auth/admin/sso/register` | +86460 s · no
cookie (**split**) | +86460 s |
| plugin-auth `auth-plugin.ts:2594` | `POST
/api/v1/auth/admin/unlock-user` | +86460 s · no cookie (**split**) |
+86460 s |
| plugin-auth `auth-plugin.ts:2912` | `POST
/api/v1/auth/admin/has-permission` | +86460 s · no cookie (**split**) |
+86460 s |
| plugin-webhooks `webhook-outbox-plugin.ts:482` | `POST
/api/v1/webhooks/redeliver` (showcase stack) | +86460 s · no cookie
(**split**) | +86460 s |
| service-storage `storage-service-plugin.ts:843` | `GET
/api/v1/storage/upload/chunked/:id/progress` | +86460 s · no cookie
(**split**) | +86460 s |
| plugin-sharing `sharing-plugin.ts:940` (not in the PM census) |
`DELETE /api/v1/share-links/:id` | +86460 s · no cookie (**split**) |
+86460 s |
| service-settings `settings-service-plugin.ts:299` (not in the PM
census) | `GET /api/settings` | +86460 s · no cookie (**split**) |
+86460 s |
| service-datasource `admin-routes.ts:212` (not in the PM census) | `GET
/api/v1/datasources/drivers` | +86460 s · no cookie (**split**) | +86460
s |

**The fix there is the same rule:**
`api.getSession(inProcessSessionReadInput(headers))`. Five of the six
packages already depend on `@objectstack/types`; `plugin-webhooks` would
gain that one dependency.

## Pins, and the tier each runs in

All pins run in each package's `local` vitest project (CI: `Test Core`).
The runtime pin boots an in-process `ObjectKernel`, with no spawned
process and no driver socket.

- `packages/types/src/in-process-session-read.test.ts`: the cookie test
across every spelling better-auth writes (default and custom
`cookiePrefix`, `__Secure-`). Also: other cookies, empty values, plain
header records, and bearer-only. The input builder passes the same
headers object through and adds `query` only for a cookie.
- `packages/runtime/src/in-process-session-renewal.pin.test.ts`, against
real better-auth:
- It reads `expiresIn` and `updateAge` off the running instance. A
precondition proves the fixture renews: a bare in-process read without
the rule moves `expires_at`.
- Three doors, each by cookie and by bearer: `GET /data/:object` (rest),
`GET /auth/me/permissions` (hono) and `GET /i18n/locales` (dispatcher
`resolveExecutionContext`).
- Pinned for each: a cookie request leaves cookie and session expiry
aligned (no renewal, no cookie). A bearer-only request still renews to
`now + expiresIn`, and no cookie is set on its response.
- The rate limiter's reader (`resolveSessionPrincipalId`) by cookie and
by bearer. After the limiter's read, `get-session` still renews AND
re-issues the cookie.
- Control: `get-session` renews and re-issues with `Max-Age =
expiresIn`.
- `packages/rest/src/in-process-session-read.pin.test.ts`,
`packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts`,
`packages/cloud-connection/src/in-process-session-read.pin.test.ts`:
every remaining reader hands better-auth the rule's input. The cases are
cookie, cookie plus bearer, and bearer-only. Covered: REST's getter and
gate re-read; the Hono resolver; the cloud-connection session bridge,
`resolveActiveOrgId` and `resolveInstallPrincipal`.
- `packages/rest/src/execctx-authz-input-seam-reachability.test.ts`: its
source-text pin on the auth-gate re-read now reads the new argument. Its
intent is unchanged: still the raw, throwing api call.

## Ablation, run twice: drop the rule from one reader

Both runs used `scripts/ablation-replace.mjs` in wrap mode, inside a
script with an absolute-path restore trap. In both suites the mutated
reader resolves from `src` (rest: a relative import; runtime: the
`@objectstack/rest` alias and a relative import), so no `dist` leg
applies.

1. **rest `computeExecCtx` getter** reverted to `api.getSession({
headers: h })`. The anchor went 1 → 0 and the blob `89fae0b5e5f5` →
`677bb44cb288`.
- Runtime pin: **1 failed | 10 passed**. Exactly `GET /data/:object — by
cookie` went red: "the session renewed (+86460 s) but its cookie was not
re-issued".
- rest pin: **2 failed | 1 passed** (both cookie cases red; bearer-only
green).
- Restored: blob == HEAD `89fae0b5e5f5`, and `git diff HEAD` is empty.
2. **runtime `resolve-execution-context` getter** reverted the same way.
The blob went `c570e6e4cd84` → `2e86b8e71527`.
- Runtime pin: **1 failed | 10 passed**. Exactly `GET /i18n/locales — by
cookie` went red.
   - Restored: blob == HEAD `c570e6e4cd84`, and the diff is empty.

## Verification

All at HEAD `8200f5778`, the last commit on this branch:

- **Unit tests** (`pnpm --filter PKG test`, each package's `local`
project):
  - `types`: 25 files, 749 passed.
  - `rest`: 264 files, 4954 passed, 326 skipped.
  - `runtime`: 341 files, 4787 passed, 19 skipped.
  - `plugin-hono-server`: 28 files, 329 passed.
  - `cloud-connection`: 42 files, 514 passed.
- `test:repo`: `types` 11, `rest` 191 (1 skipped), `runtime` 751, all
passed.
- **Typecheck** for the five packages: 41 of 41 turbo tasks green. Each
test layer compiles; runtime is at its existing ledger (27 files, 190
errors), unchanged.
- **Gates**: the 67 commands that `node scripts/pm/dispatch-gates.mjs
--commands` derives for this diff. That is the order's 61 plus
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure`, `check:type-check-coverage`,
`check:type-check-debt` and `check:where-matcher`. All exit 0. The
`--ran` reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0
UNRUN.
- **Lint**: the full `pnpm lint` (`eslint . --no-inline-config`) exits
0.
- **Not merged with `origin/main`**, which is two commits ahead (objectstack-ai#22351
cli, objectstack-ai#22352 plugin-security and plugin-auth). Neither touches a file
here, and CI tests the merge ref.

## Acceptance notes

- **Residue the server cannot see.** Some browser requests carry the
bearer without the cookie (a cross-origin fetch without credentials, or
a blocked cookie). Those read as bearer-only and still renew in-process.
If the same browser sends that session's cookie on other requests, that
cookie can still fall behind. The rule decides from what each request
carries.
- **A behaviour change for a tab that never calls `get-session`.** Such
a tab now signs out at the session's real expiry, instead of keeping a
live bearer beside a dead cookie. The console calls `get-session` on
mount and on each re-resolution.
- **Declaration drift, for `domain:spec` (not edited here).**
`AuthSessionApi` in `packages/spec/src/contracts/auth-service.ts`
declares `getSession`'s input as `{ headers }`. Its doc says every
reader "calls exactly `getSession({ headers })`", which is no longer
true. The helper declares the wider slice it passes
(`query.disableRefresh`) itself; that type-checks because the extra key
reaches a non-fresh object. Carrier: the `domain:spec` seat.
- **Vendor behaviour, unchanged here.** better-auth's own `get-session`
re-issues the session cookie on a bearer-only request too (measured:
`Max-Age=604800` with a bearer). That route is better-auth's, and this
PR does not touch it.
- **Overlap.** Open PR objectstack-ai#22357 edits
`packages/runtime/src/http-dispatcher.ts` in two comment hunks (around
lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362
and 1420-1442).
- **Unread.** The cloud measurement the card cites
(`objectstack-ai/cloud` issue 2699's report and PR 2708) was not
readable from this session. H1 re-measured the defect independently on
this repo's doors.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ssion-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (objectstack-ai#22365)

Fixes objectstack-ai#22307
Clause-②: no

Executes the maintainer's ruling letter A on objectstack-ai#22307 (ruling record
6063176077): the restart path refuses too. After `sys_metadata`
hydration and before `kernel:ready`, the engine checks every
package-held permission set and position name against the environment
catalog, and a name the environment already holds fails the boot with
the 422 `NAMESPACE_CONFLICT` envelope the package door uses, naming both
holders. A cold boot, a hot install and an artifact boot now answer
alike (Q4 = A, ruling record 6050490870).

The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft
PR, from branch `claude/issue-22307-adr-0048-n3-amendment`. This PR
carries no `docs/adr/**` file.

## What changed

- **`packages/objectql/src/plugin.ts`.** `ObjectQLPlugin.start()` calls
a new private `refuseEnvironmentHeldSecurityCatalogNames()` right after
the hydration block (`restoreMetadataFromDb`, or the project-kernel skip
line) and before Phase 3's schema sync. Any conflict throws
`SecurityCatalogNameConflictError` with `door: 'cold-boot'`, which fails
`start()` and with it the boot. It runs whether or not the kernel
hydrated.
- **`packages/objectql/src/registry.ts`.**
- A private `SchemaRegistry.environmentHeldSecurityCatalogConflicts()`
returns every package-held position and permission-set name that also
has a bare-slot item. Built-in names are skipped. Results are sorted by
type, then name.
- A private `securityCatalogPackageHolders()` reads the package half of
the holder reading: composite slots and install claims, never the bare
slot.
- A module-level `findEnvironmentHeldSecurityCatalogNames(registry)` is
the plugin's handle on that reading. It is not re-exported from
`index.ts` or `core.ts`, so the public surface does not grow.
- `SecurityCatalogNameConflictError` takes an optional `{ door:
'cold-boot' }`, which changes only the message: which package declares
each name, and a remedy stated for a restart. `code`, `status`,
`httpStatus` and `conflicts[]` are unchanged.
- **`packages/objectql/src/security-catalog-namespace.ts`.**
`ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` (`position`, `permission`: the
two types the metadata-type registry declares `allowRuntimeCreate:
true`), and a module-doc section, "The cold boot".
- **`.changeset/22307-cold-boot-catalog-refusal.md`** (new).
`'@objectstack/objectql': major`, the BREAKING banner, the ADR-0087
marker `not-required (no-migration-prescription)`, the upgrade shape and
the remedy.
- **`.changeset/22135-security-catalog-one-holder.md`** (pending, not
yet released). See Acceptance notes, "A pending release note this PR
corrects".
-
**`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.**
The invariant gains the cold-boot half.

No new error code, no `packages/spec` change.

## Where each refusal sits (for the merge with objectstack-ai#22331, which landed
first)

`main` was merged at e3ae92a, after objectstack-ai#22331 landed. The merge was
clean, and the order in `ObjectQLPlugin.start()` on this head is:

1. objectstack-ai#22331's `installDeploymentPlatformGlobalObjects(ctx)`, the first
statement of `start()`.
2. `restoreMetadataFromDb(ctx)`: `sys_metadata` hydration.
3. **This PR's `refuseEnvironmentHeldSecurityCatalogNames()`**: right
after the hydration `if`/`else` and before Phase 3's
`installRegisteredSchemas`. It runs before any plugin that depends on
the engine starts, and before `kernel:ready`.
4. objectstack-ai#22331's `assertDeploymentPlatformGlobalObjectsUnchanged(ctx)`, at
the top of the `kernel:ready` hook.

The two changes share no hunk. This PR's new method sits directly after
`restoreMetadataFromDb`'s method body, and its import line comes after
the `picklist-resolution` import block.

## Mechanism assumptions, measured

- **M1, the admission today.** Reproduced through `bootStack` on one
database file, on the untouched base 28bff18. Boot 1 saved a
permission set and a position through `PUT /api/v1/meta/permission/NAME`
and `PUT /api/v1/meta/position/NAME`. Both answered `200`; a new
position name needs no `OS_METADATA_WRITABLE`. Boot 2, cold, added a
package declaring both: it booted, with two `[Registry] Collision`
warnings, and the by-name read answered the environment's definitions.
Boot 3 hot-installed the same package: `422 NAMESPACE_CONFLICT`, both
names held by `environment`.
- **M2, where the check sits.** As above. Boot shapes:
- standalone `os serve` / `os dev` / `bootStack`: `environmentId` unset,
hydration runs, the check runs (measured, dogfood);
- the artifact boot (`createStandaloneStack`): `environmentId:
'env_local'` with `hydrateMetadataFromDb: true`, hydration runs, the
check runs (measured, runtime pin);
- a project kernel with `environmentId` and no `hydrateMetadataFromDb`:
hydration is skipped, and the check runs over whatever reached the bare
slot, normally nothing (code reading);
- a host with no `protocol` service, or one without `loadMetaFromDb`:
nothing hydrates, and the check runs with nothing to judge (code
reading).
`loadMetadataFromService` at the top of `start()` syncs `object`,
`view`, `app`, `flow` and `hook` only, so no other boot-time path writes
these two types into the bare slot.
- **M3, the holder reading. Partly falsified, route changed by the
ruling's intent.** At a cold boot the hydrated environment row is NOT an
unstamped bare-slot item. Hydration runs after the package registered,
and the protocol's artifact-protection merge grafts the package's
envelope onto the stored row. Measured on base: the bare slot
`probe22307_set` carries `_packageId: com.probe.addon22307` and
`_provenance: package`, so objectstack-ai#22197's stamp-based reading answers "the
package itself" and finds no second holder. The check therefore reads
every bare-slot item as the environment's, whatever stamp it wears: only
a registration with no package writes the bare slot. A package holds a
name through a composite slot or a claim, never through the bare slot.
The envelope class, holder kinds and claims are objectstack-ai#22197's.
- **M4, built-ins.** Built-in names are skipped. Through `bootStack`,
with `OS_METADATA_WRITABLE=position`, environment saves under
`org_admin` and `everyone` answered `200`, and the restart boots, with
`GET /api/v1/meta/position/org_admin` answering the saved definition.
S2b's pins are green: `builtin-positions.boot.test.ts` is in the
plugin-security suite below.
- **M5, the legacy shape.** The save door refuses it now (`PUT
/api/v1/meta/permission/NAME` over a package-held set answers `403`,
with or without `?package=`), so the rows were written at the driver. A
row bound to no package refuses the restart, naming both holders
(pinned). So does a row bound to the package itself (`package_id` = the
package; objectql pin). A hot install refuses that bound row alike:
measured, holder `environment`. A legacy row over one of the platform
security plugin's own permission sets (`member_default`) refuses the
restart, naming `com.objectstack.plugin-security`. On base, all three
boot.
- **M6, capabilities.** `PUT /api/v1/meta/capability/NAME` answers `403`
("code-only … allowRuntimeCreate=false"), so the environment catalog
holds no capability. The check reads permission sets and positions only,
and no capability path reaches it.

## Door table: base vs head

"Base" is the untouched 28bff18, or a15b8af with the check
ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes
comments only). Boots go through `@objectstack/verify`'s `bootStack` on
one database file unless the row says otherwise.

| Door | Base | Head |
|---|---|---|
| Cold boot: environment-saved permission set and position, then a
package declaring both | boots; two `[Registry] Collision` warnings; the
by-name read answers the environment's definitions (28bff18 and
ablated) | refused: `Plugin com.objectstack.engine.objectql failed to
start`, cause `422 NAMESPACE_CONFLICT`, two conflicts, incoming the
package, holder `environment` |
| Hot install (post-boot `manifest.register`) of that package | refused,
`422`, holder `environment`, both names | unchanged |
| Artifact boot (`createStandaloneStack`, `file:` database), a package
added over environment-saved names | boots (ablated: runtime pin red) |
refused, same envelope |
| Built-in shadow: environment saves under `org_admin` and `everyone`,
restart | boots (ablated) | boots; the stored definition answers |
| Legacy row (written at the driver, bound to no package) over a
package-held set and position, restart | boots, one collision warning
(28bff18) | refused, holder `environment`, both names |
| Legacy row bound to the package itself, restart | boots (ablated) |
refused, holder `environment` |
| Legacy row over the platform's `member_default`, restart | boots
(ablated) | refused, incoming `com.objectstack.plugin-security` |
| Same-package restart; a package whose names the environment does not
hold | boots | boots |
| Remedy: boot without the package, `DELETE
/api/v1/meta/permission/NAME` and `/position/NAME`, boot with it | (n/a)
| both `200`, no row left, the boot with the package comes up |
| Environment save of a capability | `403` code-only | unchanged |

## In-repo census

The examples ship no `sys_metadata` rows, so the environment catalog
holds no names on a fresh boot. Measured on a15b8af: a fresh boot of
each example on a database file, then a restart.

| Example | Package-held items | Environment rows
(`permission`/`position`) after the boot | Restart |
|---|---|---|---|
| `app-crm` | 10 permission sets, 9 positions | 0 | boots |
| `app-showcase` | 17 permission sets, 16 positions | 0 | boots |
| `app-multi-package` | 8 permission sets, 6 positions | 0 | boots |

The counts include the platform's own items (`plugin-security`'s 8
permission sets and 6 built-in positions). Names held twice: 0.
`app-todo` declares no catalog name (objectstack-ai#22197's census) and is not a
dogfood dependency, so it was not booted. Deployed environments: NOT
MEASURED.

## Tests

The head is 3c160a2. Against 72dcb8e it changes comment lines
only, in the new dogfood file (5 added, 3 removed, 0 outside a `//`
comment). The runs below are at 72dcb8e or earlier, as each line
says.

- `@objectstack/objectql`, whole suite at e3ae92a: 387 files / 7615
passed. At 72dcb8e, `protocol-boot-hydration-scoped.test.ts`: 16
passed (8 of them new).
- `@objectstack/plugin-security`, whole suite at e3ae92a: 184 files /
3869 passed, 45 skipped. That includes S2b's
`builtin-positions.boot.test.ts` and
`bootstrap-declared-positions.test.ts`.
- `@objectstack/runtime`, whole suite at e3ae92a: 340 files / 4777
passed, 19 skipped.
`standalone-stack-security-catalog-one-holder.test.ts` has 6, 1 of them
new.
- Dogfood, the CI split, at e3ae92a:
  - 1/3: 76 files / 567 passed;
  - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);
  - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).
The one red was this PR's own built-in control: its `PUT
/api/v1/meta/position/org_admin` answered `403` with the hatch set. The
protocol memoises `OS_METADATA_WRITABLE` at its first read in a process,
and the control set it only after the file's first case had already
saved through the metadata door. It passed in isolation before the
second merge and failed in the full shard after it; what made that
difference is NOT MEASURED. At 72dcb8e the file opens the hatch
before its first boot. The new file and the re-shaped Discard Overlay
file then ran: 2 files / 11 passed.
- Before the second merge, at bdfba35: dogfood 1/3 76 files passed;
2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since);
3/3 74 passed and 1 skipped.
- `typecheck` at 72dcb8e: `objectql` (`tsc --noEmit` plus
`check:test-typecheck`: 40 files, 234 errors, 65 pinned signatures, no
new signature) and `dogfood`, exit 0. `runtime` at e3ae92a, exit 0;
no runtime file changed after it.
- `pnpm exec eslint --no-inline-config --format json` over the 7 touched
TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This
narrowed run is a measurement, not a skipped one, on three grounds:
- the population comes from `eslint.config.mjs` itself (`files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`), and all
7 files are in it;
  - the count, 7, is read from the JSON output;
- the config enables no type-aware linting (no `parserOptions.project`,
as stated at `eslint.config.mjs:328`), so this diff cannot move any
untouched file's verdict.
  The whole-repo `pnpm lint` is CI's.

## Ablation

The call was neutralised through `scripts/ablation-replace.mjs`, which
wraps the run and restores on exit. In `plugin.ts`,
`this.refuseEnvironmentHeldSecurityCatalogNames();` became the same call
behind an always-false guard carrying the marker
`ABLATION_22307_MARKER`, so the method stays referenced and the DTS
build still runs.

- **Landed on disk:** anchor 1 → 0, replacement 0 → 1, blob
`399ddf47c127` → `2cf40c49e6e2`. `objectql` was rebuilt (exit 0), and
`ablation-dist-preflight` found the marker in 2 built files.
- **objectql pins (from `src`):** 5 failed / 11 passed of 16 in
`protocol-boot-hydration-scoped.test.ts`. All 5 refusal pins went red:
per type, the environment-held name and the row bound to the package,
plus every conflict in one refusal. The controls stayed green: distinct
names per type, and a built-in name the platform declares beside a
stored definition.
- **runtime pins (from `dist`):** 1 failed / 5 passed. The artifact-boot
case went red; objectstack-ai#22197's five stayed green.
- **dogfood pins (from `dist`):** 2 failed / 2 passed. The cold-boot
case and the legacy-row case went red; the built-in shadow and
distinct-name controls stayed green.
- **Base readings under ablation** (an uncommitted probe): the cold boot
booted with two collision warnings; the row bound to the package booted
cold and was refused hot; the `member_default` overlay booted; S2b
booted.
- **Restore:** blob back to `399ddf47c127` == HEAD, `git diff HEAD`
empty, `git status --porcelain` empty. After a rebuild,
`ablation-dist-preflight --absent` is green: the marker is absent from
all 14 built files and the tree is clean.

The ablation ran at a15b8af. The second `main` merge (e3ae92a)
brought objectstack-ai#22331's `plugin.ts` hunks, none of them on this check's lines,
and the refusal pins were re-run green at 72dcb8e.

## Clause-② (measured on the built entry declarations at 72dcb8e)

`packages/objectql/dist/{index,core}.d.ts` and the shared chunk declare
no new exported name. `findEnvironmentHeldSecurityCatalogNames`,
`ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` and
`SecurityCatalogNameConflictError` are absent from the entries' export
lists. The only new declaration text is three private member names
(`SchemaRegistry.environmentHeldSecurityCatalogConflicts`,
`SchemaRegistry.securityCatalogPackageHolders`,
`ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) plus JSDoc.
No widening was found, so `Clause-②: no` stands.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` derived 81 commands at
the head, 3c160a2. All 81 ran with exit codes recorded, and `--ran`
reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The
same 81 were derived and run at 72dcb8e, with the same answers.

One exited 1, by design: `check-empty-changeset --base origin/main`. It
is the deliberate correction of objectstack-ai#22135's pending note (see Acceptance
notes), and the gate's own text says to confirm that class on the PR,
not restore the note.

On e3ae92a, `check:dual-build-cjs-loads` first answered PREREQUISITE
NOT MET (exit 3) until eight packages outside this change were built:
`studio`, `client-react`, `embedder-openai`, `knowledge-memory`,
`knowledge-ragflow`, `organizations`, `service-cluster-redis` and
`service-knowledge`. On 72dcb8e and 3c160a2 it exits 0.

The changeset gates: `check-changeset-no-major --base` exits 0 (pre mode
`next`), `check:adr-0087-registration` exits 0, and
`check:changeset-gate-self-tests` exits 0.

CI's own lanes are declared to CI and are NOT MEASURED here: the Test
Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and
the workspace type-check lanes. `origin/main` is 7 commits ahead of the
head, among them objectstack-ai#22352 (`plugin-security` grant readers) and objectstack-ai#22353
(`metadata-protocol` seed loader); none touches a file of this PR. `git
merge-tree` against it is clean, so `main` was not merged again.

## Acceptance notes

- **A pending release note this PR corrects (`check-empty-changeset`
stays red by design).**
`.changeset/22135-security-catalog-one-holder.md` is objectstack-ai#22135's pending
note, not yet consumed by a release (`packages/objectql` is at
`17.7.0`). Its "What is NOT refused" paragraph said a package added at
cold boot over an environment-held name "is not refused at cold boot".
On this PR's merge that sentence is false, and both notes would publish
in the same release. That one sentence now says the door cannot see the
name at cold boot, and that the engine checks it right after the
environment catalog loads and refuses the boot. Nothing else in the note
changed. The gate's own text names this shape a DELIBERATE CORRECTION,
to be confirmed on the PR, not restored. If a release consumes the note
before this PR lands, the edit no longer reaches a published CHANGELOG,
and the correct move then is an erratum PR against that CHANGELOG entry.
- **The 2026-08-24 legacy-overlay remedies lose their boot-time
population for code-package-declared sets.** The overlay detection
reading and the drift pass's `overlay_shadow` run in `plugin-security`'s
`kernel:ready`. A boot carrying an environment overlay of a
package-declared set is now refused before `kernel:ready`, so on a
deployment that boots, those branches see no such overlay. The same
holds for the Discard Overlay action's discard path for such a set. The
ruling names this cost ("including rows saved before the packaged
locks"). The upgrade route is in the changeset: rename, or remove the
row. A deployment can also run Discard Overlay on the release it runs
now, before upgrading.
`permission-set-discard-overlay-eligibility.dogfood.test.ts` (objectstack-ai#21860's
pin) wrote its legacy overlay before a cold boot, which is now refused.
It now writes the overlay into the running deployment and runs the two
passes the boot ran for it, by the functions the security plugin's boot
calls (`reconcilePermissionSetProjection`, then the drift pass), so its
preconditions and its control still hold.
- **The refusal leaves `start()`, so the kernel wraps it.**
`bootstrap()` rejects with `Plugin com.objectstack.engine.objectql
failed to start - rollback complete: …`, and the envelope is the
wrapper's `cause`, as with any `start()`-time refusal (objectstack-ai#22197's
item-seam refusal from `plugin-security.start` included). The pins read
`cause`.
- **Org-scoped rows are not judged.** Boot hydration loads env-wide rows
only (`organization_id IS NULL`), and org-scoped rows never reach the
registry, so the check judges the env-wide catalog. That is the
population hydration serves.
- **A refused boot over a `sqlite-wasm` file can still flush after the
refusal.** In a probe, removing the database directory right after the
refused `bootStack` raised `ENOENT` from the driver's atomic write. The
committed dogfood file keeps its database files in the test file's
working directory, which the dogfood run removes at its end, and never
boots a file again after it was refused. Noted, not filed: a boot that
failed has no process left to serve.
- **Files outside the engine lane:**
-
`packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts`
(new) and
`packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts`
(re-shaped, above): `domain:cli`.
-
`packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts`
(one case added, and the artifact-stack helper takes a `databaseUrl`):
`domain:cli`.
-
`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.
  - `.changeset/22135-security-catalog-one-holder.md` (above).

## Patch round 1 — the release note's remedy, completed

Both contract reviews passed: 6070947709 on this PR, which also confirms
the correction of objectstack-ai#22135's pending note, and 6070955792 on the ADR PR.
This round changes text only. The code, the pins and
`.changeset/22135-security-catalog-one-holder.md` are unchanged. The
head is cf1a9dd.

- **`.changeset/22307-cold-boot-catalog-refusal.md`.** "The upgrade
shape" names the legacy plural types. "The one-line fix" now has three
parts:
- **Before upgrading, for a permission set.** The `kernel:ready` overlay
reading names the sets this release refuses. The audited Discard Overlay
action, or `DELETE /api/v1/meta/permission/NAME`, removes each overlay
without touching the database, including on the platform's own sets.
- **After upgrading, for a package that can be left out.** Boot without
it, then delete through the metadata API.
- **After upgrading, for a name the platform security plugin declares.**
The SQL delete of the active, environment-wide rows under the type or
its legacy plural.
The changeset also says that no `os` command deletes a `sys_metadata`
row offline.
- **`content/docs/permissions/permission-sets.mdx`.** One clause under
"Declared ≠ enforced", on the Discard Overlay remedy: discard such an
overlay before you upgrade, because a deployment that still holds one
does not boot.

**Measured, clause by clause:**

- **The current release.** This branch with the check ablated through
`scripts/ablation-replace.mjs` (blob `9b18363e90ef` → `b3701fcc3a70`,
marker in `dist/`), a legacy `member_default` overlay written at the
driver, then a restart:
- The boot logged one `kernel:ready` warning, "[security] 1
package-declared permission set(s) are being shadowed by an environment
overlay — … use the audited "Discard Overlay" action on it …", naming
`member_default`.
- The record read `drift_status: overlay_shadow`, and Discard Overlay
answered `200` and left no active row.
- On the same release, `DELETE /api/v1/meta/permission/viewer_readonly`
over a legacy overlay of that platform set answered `200`
("Customization overlay deleted — permission/viewer_readonly reset to
artifact default") and left no active row. So Discard Overlay is not the
only database-free remedy before the upgrade; the changeset names both.
- **The restore.** `ablation-replace` put the blob back (== HEAD, `git
diff HEAD` empty). After the rebuild, `ablation-dist-preflight --absent`
was green on `dist/` at once. It was green on the tree once this round's
doc edit, the one dirty path at that moment, was committed (cf1a9dd).
- **The head, check live:**
- The database on which the current release ran Discard Overlay on
`member_default` boots.
- Rows of type `permissions` and `positions` (the legacy plurals) over
package-held names refuse the restart, both named.
- A `draft` row over a third package-held name is not loaded and not
named.
- `loadMetaFromDb` selects `state: 'active'` and `organization_id:
null`, and folds the type through `PLURAL_TO_SINGULAR`, which maps
`permissions` to `permission` and `positions` to `position` on `main`.
It sets no `package_id` condition: a row bound to the package itself
refuses too, measured in the first round.
- **The SQL.** The changeset's `DELETE` statements, run through Python's
`sqlite3` against the refused database files (one per type, and one for
`member_default`), deleted 1 row each. Each restart then booted.
- **The CLI.** `os meta delete` and `os data delete` build an API client
and require a token (`createApiClient`, `requireAuth`), and no command
under `packages/cli/src/commands` deletes a `sys_metadata` row.
- **The action.** `discard_permission_set_overlay`, labelled "Discard
Overlay", on `sys_permission_set`, in the list-item and record-header
locations, visible while `drift_status` is `overlay_shadow`. It is
documented on `content/docs/permissions/permission-sets.mdx` under
"Declared ≠ enforced — diagnosing a frozen package set". Positions have
no overlay reading (it reads the `permission` / `permissions` types) and
no such action.
- **NOT MEASURED:** the metadata API delete on a set a non-platform
package ships, and a position overlay before upgrading.

**Gates at cf1a9dd.** `dispatch-gates --commands` derived 107
commands; the doc page added the docs families. All 107 ran with exit
codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106
exited 0, including `check-changeset-no-major --base`,
`check-adr-0087-registration --base`, `check:doc-authoring`,
`check:docs-*`, `check-doc-frontmatter`, `@objectstack/spec`'s
`check:docs` and `check:doc-formula-expressions`. One exited 1 by
design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135
correction. `origin/main` is 12 commits ahead; `git merge-tree` against
it is clean, so `main` was not merged.

**One more file outside the engine lane:**
`content/docs/permissions/permission-sets.mdx` (`domain:devx`).

## Patch round 2 — the metadata-API delete reaches singular-typed rows
only

The at-tier contract review on cf1a9dd (6071828819) failed two remedy
sentences, and judged everything else right: the code, the objectstack-ai#22135
correction (confirmed on that head), case 3's SQL, the CLI sentence, the
docs clause and the semver. The two sentences are case 1's "So does
`DELETE /api/v1/meta/permission/NAME`" and case 2's metadata-API delete.
Both are false for a row stored under the legacy plural `permissions` /
`positions`, a shape the changeset's own "upgrade shape" paragraph
names. This round changes
`.changeset/22307-cold-boot-catalog-refusal.md` only. No code, pin, docs
page or `.changeset/22135-security-catalog-one-holder.md` change. The
head is 39ef237.

**Measured first; the review's reading holds.**

- **The current release** (this branch with the check ablated through
`scripts/ablation-replace.mjs`, blob `9b18363e90ef` → `b3701fcc3a70`,
marker in `dist/`):
- A legacy overlay of `viewer_readonly` stored under `permissions`:
`DELETE /api/v1/meta/permission/viewer_readonly` answered `200` with
`{"success":true,"reset":false,"message":"No customization overlay found
for permission/viewer_readonly — already at artifact default."}`, and
the `permissions` row stayed active. Discard Overlay on the same set
answered `200` and left no active row.
- `mcp_agent_restricted` with two active rows, one bound to no package
and one bound to `com.objectstack.plugin-security`: the first `DELETE`
answered `200` "Customization overlay deleted — … reset to artifact
default" and removed one row, leaving the bound one. A second `DELETE`
removed it.
- **The head, check live, case 2.** A package's permission set and
position stored under `permissions` / `positions`. Booted without the
package, `DELETE /api/v1/meta/permission/pr2_set` answered `200` "No
permission 'pr2_set' found — nothing to delete.", and `DELETE
/api/v1/meta/position/pr2_pos` answered "No position 'pr2_pos' found —
nothing to delete." Both rows stayed active, and the boot with the
package added back was refused, both names held by `environment`.
- **The restore.** Blob == HEAD and `git diff HEAD` empty. After the
rebuild, `ablation-dist-preflight --absent` is green on `dist/` and on
the tree.

**The text fix, as the record names it:**

- **Case 1:** "neither touches the database" now reads "neither needs
direct database access".
- **Case 3's heading** now reads "for a name the platform security
plugin declares, or for any row the metadata API does not reach".
- **One paragraph after the three cases**, before the CLI sentence:
- the two `DELETE` routes reach a row stored under `permission` or
`position` only, one row per call;
- a plural-typed row is not reached: `200`, nothing found, nothing
removed;
  - where a name has two active rows, each call removes one;
- a plural-typed row is removed by Discard Overlay before upgrading (a
permission set), or by the SQL above after upgrading, for any name.

This also corrects round 1's summary above: the metadata-API delete is a
database-free remedy before the upgrade only for a row stored under the
singular type.
- `content/docs/permissions/permission-sets.mdx`'s clause does not name
the metadata-API delete, so the page is unchanged.

**Gates at 39ef237.** `dispatch-gates --commands` derived 107
commands. All 107 ran with exit codes recorded, and `--ran` reconciles
107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design:
`check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135
correction. `origin/main` is 22 commits ahead. `git merge-tree` against
it is clean, so `main` was not merged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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