Repository navigation
feat(plugin-security,plugin-auth): the grant readers read the permission-set name column (ADR-0131 D4, C2 stage S5b) - #22352
Conversation
…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>
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>
…b-grant-readers-by-name
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 13 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
… 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>
…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>
Part of #15196
Clause-②: no
ADR-0131 D4, C2 stage S5b. The
domain:servicesgrant readers key onsys_user_permission_set.permission_set, the grant's permission set by name, instead ofpermission_set_id. S4a dual-writes that column and S4b backfills it. Untouched: the resolver in@objectstack/core(S5a),verify(S5c),security-plugin.ts,packages/specand 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 isorigin/mainat worktree creation;fe98cc63..28bff18d0ctouches onlypackages/cli. The census was taken ate67ba80049.explain-engine.ts·collectGrantProvenance(grants)permission_set_idpermission_setexplain-engine.ts·collectGrantProvenance(set rows)readGrantSetRows)delegated-admin-gate.ts·assertDirectGrantWritedelegated-admin-gate.ts·loadSetRowByIdloadSetRowForGrantbootstrap-platform-admin.ts·findPlatformAdminGrantHolderlegs A and Badmin_full_access; leg C (new) is described under "A grant whose name is empty"bootstrap-platform-admin.ts·findExistingPlatformAdminbootstrap-platform-admin.ts·bootstrapPlatformAdminpromoteauto-org-admin-grant.ts·resolvePermissionSetIdauto-org-admin-grant.ts·resolvePermissionSetIdsForNameauto-org-admin-grant.ts·reconcileOrgAdminGrantsuperseded / existing / revoke readsgrantsHoldingName)auto-org-admin-grant.ts·backfillOrgAdminGrantsorphan sweepauth-manager.ts·findPermissionSetRowsactive)auth-manager.ts·settleSelfRegistrationGrantexistence(user, id)(user, name)ensure-default-organization.ts·ensureDefaultOrganizationadmin_full_accesslast-admin-guard.ts·resolveAdminUserIdslast-admin-guard.ts·refuseIfEmptiedRatherThanFresh$ninknown ids$ninknown nameslast-admin-guard.ts· grant and set hookspermission_setjoinsGRANT_STANDING_KEYSThe census predates no other by-id grant read in these seven files. The other by-id reads there (
delegated-admin-gate.tsbinding write andsetsBoundToPosition) 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 atkernel:ready, earlier (security-plugin.tsnear :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:names no permission set); a tenant admin is not judged herereason: 'admin_grant_unnamed', warn). It names noadminUserId, sofindExistingPlatformAdminanswersundefinedno_admin; no decision is recorded, so a later trigger bindsThe 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.tscovers explain, platform standing and organization-admin standing per principal (platform admin, organization admin, member, agent) insingle,groupandisolated.plugin-auth/src/grant-readers-by-name.golden.test.tscovers the last-admin guard's verdicts and the default-organization bind.856ed8f1ad(goldens only, production code at base), green there, and green with the change.856ed8f1ad: all four mutations together leave both suites green (3/3 and 3/3). Base readers read the id.singleposture: the re-run promotes,already_have_adminis lost);no_admin, and the guard verdicts change).scripts/ablation-replace.mjsin wrap mode, and each restore was proven: blob equals HEAD andgit diff HEADis empty.Durability gate
persistGrantNameBackfillRecordjoinsDURABILITY_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 towarn, and the gate went red atgrant-permission-set-name-backfill.ts:550. Restore proven by blob.Verification
All at head
fff0b0adb7(main5ff7cbe364merged in ase6879a88d1) unless named.@objectstack/plugin-security: vitest 185 files, 3880 passed, 45 skipped.typecheckexit 0; test layer 0 files / 0 errors / 0 debt.@objectstack/plugin-auth: vitest 132 files, 2672 passed, 10 skipped.typecheckexit 0; test-layer debt unchanged at 10 files / 94 errors / 23 signatures.e6879a88d1). 45 passed and 1 skipped (rls-multitenant's ownskipIf); 436 tests passed, 3 skipped.grant-readers-unnamed-grant.test.tsin both packages (7 + 7), and the pre-image block indelegated-admin-gate.test.ts(5).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, becausecheck:swallow-census-controlswent red on the drift.--self-testand--self-test=gatedboth exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 91 commands atfff0b0adb7, and every one exited 0, captured before any pipe.--ran: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN.pnpm check:error-status-conformanceis in that set, and it also exited 0 when run by hand.eslint --no-inline-config --format jsonover the 30 changed TS/MJS files. 30 linted, 0 errors, 0 warnings, none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so untouched files' verdicts cannot move. The fullpnpm lintis CI's.origin/mainis 3 commits ahead of the head. The only overlap is a comment-only hunk inauto-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
permission_set. Clearing the last administrator's grant name is refused (pinned). With the S4a hooks bound, a cleared name was already written back.callerOrganizationId's docblock now states the landedsinglebehaviour (the Default Organization), per the seat's note. Comment only.Generated by Claude Code