Repository navigation
feat(security)!: retire the walled legacy platform-admin grant dual read and its deprecation log (#11663 L5) - #19136
Conversation
… deprecation pointer Source half only; tests and changeset follow. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
…ead and its deprecation log Under a walled tenancy posture an unscoped `admin_full_access` grant row no longer confers PLATFORM_ADMIN; standing there is config-derived only. The once-per-process migration pointer retires with it, removing `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` from `@objectstack/core`'s published entry. The `single` posture is untouched: its zero-config first-user promotion and its grant row still confer, per Choice 4A. The boot-side fail-closed line is no longer skipped when a legacy holder is present — such a rig now has zero platform administrators, so it is told, at error, naming the variable, the row and its holder. Also deep-copies the recorded `where` in this suite's ObjectQL double: it stored a live reference to `grants.positions`, which the resolver mutates after issuing the query, so a multiset comparison read the array's final contents instead of the query as issued. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
📓 Docs Drift CheckThis PR changes 3 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 40 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 d93878c5d29f897f3b1604e008b846aec064fd22 && git checkout d93878c5d29f897f3b1604e008b846aec064fd22
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1047fe10166c943807d61218e2fde9c3ab22e502 e632839ce6f53ac2d6d8faa911cf266f7add57ca && git checkout -B drift-repro 1047fe10166c943807d61218e2fde9c3ab22e502 && git merge --no-ff e632839ce6f53ac2d6d8faa911cf266f7add57ca
node scripts/docs-audit/affected-docs.mjs --json 1047fe10166c943807d61218e2fde9c3ab22e502
|
Contract reviewServed-tier: Isolated at-tier review. Judged against the diff at that head (8 files, 3 commits), the card's own comments (the option-B ruling ① Derived judgmentsEvery change to the accept/reject set and to the published surface that the diff produces, one by one:
🔴 Finding — the walled retirement is done at one of the two readers of the same anchor. ② Semver levelConvention read from Naming: incomplete on a strict reading. Per ① item 6 the removed names also leave ③ Boundary flags
Verdict reasoning. The diff does what the ruling authorises, keeps Implemented-by: VERDICT: FAIL Generated by Claude Code |
…tire-walled-legacy-grant-dual-read
The break-glass guard enumerated platform administrators from the same unscoped in-window `admin_full_access` row on every posture, while the derivation site stopped deriving standing from it under a wall. One anchor, two readers, disagreeing: on a walled rig holding a legacy row the guard counted a holder the derivation no longer recognises, so a write ending the last config-anchored administrator's standing was permitted, and a write revoking the inert row was refused as though it removed the last one. Grade 1 is now keyed on the identical expression the derivation uses. The `single` posture is unchanged and pinned so in both directions. The two zero-population refusals gained a walled clause, because restoring the `admin_full_access` row stopped being a remedy that ends the emptiness there. Also names `@objectstack/plugin-auth`, `@objectstack/runtime` and `@objectstack/plugin-hono-server` in the changeset, and posture-qualifies the admin-standing surface's reason prose. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
…itly The guard's grant-anchored grade is posture-keyed now, so a suite that leaves the tenancy inputs unset measures whatever the box exports rather than the posture its cases were written against. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
…me prose `check:doc-authoring` reds on an internal issue id inside a customer-facing string, and the posture qualifier added two. The sentence says the same thing without them. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
Contract reviewServed-tier: Isolated at-tier review of the new head after patch round 1. Judged against the diff of that head over its merge base ① Derived judgments
② Semver levelLevel: correct. Convention read from Naming: correct and not over-named for what this diff does. ③ Boundary flags
Verdict reasoning. The patch round did what it set out to do: the guard is keyed on the derivation's own predicate and pinned in both directions with Implemented-by: VERDICT: FAIL Generated by Claude Code |
…tire-walled-legacy-grant-dual-read
…min anchor
`ensureDefaultOrganization` answered "which user is the platform admin?" from
the oldest unscoped `admin_full_access` grant on every posture, while the
derivation site had stopped deriving standing from that row under a wall. That
reader does not merely count the population — it writes the account it picks a
`sys_member { role: 'owner' }` row on the Default Organization and hands them
the org's seeded rows. On a walled rig carrying a legacy row, before a declared
owner verifies, it bound an account the derivation grades MEMBER.
Step 2 is keyed on the same expression, and the bootstrap-trigger predicate's
grant-insert arm retires with it under a wall (cost only; the `sys_user` arms
are untouched, and the declared owner's verifying update is the only write that
grows the population there).
Re-authors the organizations package's pre-L5 positive-control pin to assert the
new model, and names that package in the changeset because the behaviour it
delivers on a walled rig moves.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
Contract reviewServed-tier: Isolated at-tier review, third round. Judged against the diff of that head over its merge base ① Derived judgments1. Rounds 0 and 1 are intact across round 2 and the base merge. 2. 3. Both names are gone from every published surface — re-confirmed on a fresh build of this head. I extracted the head with 4. ⭐ The closure claim, tested with my own instruments. I did not reuse the body's
Positive control: the table-shaped and name-shaped sweeps both return the four known row readers, and the standing-shaped sweep returns the resolver and each projection named above. Result: zero readers of the unscoped-row anchor that derive or confer standing and are not posture-keyed. The row-anchor reader set is closed. A dynamic-name blind spot was probed separately: the constant spellings ( What the instruments cannot see: a table name built from a template literal; 5. The family the row-anchor framing leaves out, named so it is not left to be found. The name-shaped sweep surfaces four readers of platform authority that never read the row or its scope but read the resolved envelope's NAME or CAPABILITY content: 6. The newly keyed reader. Same predicate as the derivation site (item 2), hoisted once per call ( 7. The 8. The trigger split is correct. Under a wall step 2 is skipped, so the helper's answer is a function of 9. Widening: none. Zero added 10. Dissolution, re-run on my own build rather than quoted. Baseline: ② Semver levelLevel: correct. Convention read from Naming: correct, and ③ Boundary flags
Verdict reasoning. The class is closed on my own instruments, with the four known readers as the positive control and the blind spots named. The reader keyed this round is keyed on the derivation's own predicate, its walled answer is a wait with a real re-run write behind it, and the trigger split leaves no walled deployment stranded. Rounds 0 and 1 survive the base merge byte for byte, Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② provenance — carriers cleared against this head
Three at-tier reviews, three different heads, ⛔ never a re-run for a PASS
⭐ Each FAIL was adopted verbatim and answered with a patch round on the same claim. ⛔ No head was reviewed twice, and ⛔ no review was re-run hoping for a different answer. What made the third review decisiveTwo consecutive FAILs each finding 「another reader」 was treated as a root-cause signal, ⛔ not as a reason to fix one more instance: round 2 was scoped from #11663 P3's enumeration and every reader in it was measured. This reviewer then tested that closure claim with its own instrument rather than the body's — three independent sweeps (table-shaped, name-shaped, standing-shaped), each with a positive control, plus a probe for dynamic name spellings — and declared its blind spots (template-literal table names, ⇒ zero unkeyed readers of the unscoped-row anchor. ⭐ And one reading in that sweep is worth keeping on the record: Generated by Claude Code |
…in route that works on every tenancy posture (objectstack-ai#19892) Fixes objectstack-ai#19875 Clause-②: no The `sys_user.role` field's help text told administrators to grant platform-admin standing with an unscoped `admin_full_access` assignment. Since PR objectstack-ai#19136 that remedy does nothing on a walled deployment. The help text now names the route that works on every tenancy posture and keeps the grant clause `single`-only. This is a text correction on a platform object. No behaviour changes. ## How platform-admin standing is conferred today (measured on `origin/main` 3bd221d) | posture | unscoped `admin_full_access` row in `sys_user_permission_set` | `OS_PLATFORM_OWNER_EMAIL` lists the user's verified email | |---|---|---| | `single` | confers | confers | | `group` | grants the set's capabilities, **no** standing | confers | | `isolated` | grants the set's capabilities, **no** standing | confers | - The grant-row route is `packages/core/src/security/resolve-authz-context.ts` §6b. `hasPlatformAdminGrant` is set from `ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id) && !legacyGrantAnchorRetired`, where `legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture())`. `postureEnforcesWall` (`packages/spec/src/security/tenancy-posture.ts`) is `posture !== 'single'`. - The config route is the same file's §6b-config: `configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig)`. It has no posture gate. `matchesConfiguredPlatformAdmin` and `resolvePlatformAdminEmails` (`packages/core/src/security/platform-admin.ts`) read `OS_PLATFORM_OWNER_EMAIL` and admit only an email-verified stored row. - Both readings are already pinned in `packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts`. The pins are "the legacy grant row is an anchor under `single`, and NOWHERE else" and "a CONFIG anchor resolves under BOTH postures". ## What changed **`packages/platform-objects/src/identity/sys-user.object.ts`**: the `role` field's `description`. Before: > Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`. After: > Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2). To grant platform-admin standing, list the user's verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture an unscoped `admin_full_access` assignment in `sys_user_permission_set` also confers it. The text states the posture split as it ships today. It says nothing about any future change to the `single` posture. It stays a prescription, not an exhaustive list, so it makes no claim about the legacy `role` scalar either way. **Generated artefacts, regenerated through their generator (not by hand):** - `packages/platform-objects/src/apps/translations/en.objects.generated.ts` (`sys_user.role.help`), produced by `pnpm i18n:extract`. The run wrote 11 files. The other 10 (`zh-CN` / `ja-JP` / `es-ES` objects, metadata-forms and source-hash tables, plus the `en` metadata-forms bundle) came out byte-identical. The translated `role.help` leaves have no source-hash entry, so they are hand-written and legacy-trusted, and the extract keeps them. - No other generated artefact carries the string. `git grep` over the whole tree finds it in no reference doc, JSON schema or snapshot. **The pin on the old text**, `packages/platform-objects/src/platform-objects.test.ts` (the `objectstack-ai#15188` test). It asserted only that the description contains `sys_user_permission_set` and `admin_full_access`, and those substrings survive in the new `single` clause. So restoring the old sentence would have passed every test. The pin now also requires `OS_PLATFORM_OWNER_EMAIL` and a `` `single` `` qualifier. It asserts named subjects, not wording. **`.changeset/sys-user-role-help-posture-split.md`**: `@objectstack/platform-objects` `patch`, quoting what the help text said and what it says now. ## Evidence (final head `ed9dc25cdd`) - `pnpm --filter @objectstack/platform-objects test`: **54 files / 883 tests passed**. `pnpm --filter @objectstack/platform-objects typecheck`: exit 0 (`tsc --noEmit` ×2 plus `check:test-typecheck: OK`). Both ran through the verify lock with `VERDICT command-exit 0`. - **Ablation of the updated pin** used `scripts/ablation-replace.mjs`, wrapped around `vitest run src/platform-objects.test.ts`. It restored the old description in `sys-user.object.ts`: anchor hit x1 to x0, replacement x0 to x1, blob `ca076aa169ea` to `7f8718e29dfc`. Result: **1 failed / 126 passed**, and the failure was the `objectstack-ai#15188` test with `expected 'Legacy better-auth role scalar (admin…' to contain 'OS_PLATFORM_OWNER_EMAIL'`. The expected direction was red, and red is what happened. Restore proven: blob after restore == blob at HEAD (`ca076aa169ea`), `git diff HEAD` empty, `git status --porcelain` empty. No dist involved: the suite imports the object from `src/`. - **The old string is gone from `packages/**`**: ``git grep -c -F 'grant platform-admin standing with an unscoped `admin_full_access` assignment' -- 'packages/**'`` gives 0 hits (exit 1). The control ``git grep -c -F 'verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture' -- 'packages/**'`` gives 2 hits (exit 0), one in `sys-user.object.ts` and one in `en.objects.generated.ts`. Across the whole tree it survives only in two changesets: this PR's, which quotes it as the old text, and the pending `.changeset/sys-user-role-prose-retired-action.md` (see Acceptance notes). - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) on `ed9dc25cdd` derived **60 commands**, and every one exited 0. `pnpm check:i18n` and `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: no `dist/`). I built their stated prerequisites and re-ran them, and both then exited 0. Reconciled with `--ran`: `60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN`. The live `node scripts/check-issue-citations.mjs` was run by hand: exit 0, `no issue citations added against 3bd221d`. - **Narrowed lint**: eslint `--no-inline-config --format json` over the 3 changed `.ts` files gave **3 files, 0 errors, 0 warnings**. Population: `eslint --print-config` resolves a config for each of the 3, and the JSON carries no ignored-file notice. File count: 3, read from the JSON array. Invariance: `eslint.config.mjs` enables no type-aware linting for any file (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - NOT MEASURED locally, left to CI: the path-scheduled CI jobs and the type-check lanes that `dispatch-gates` lists outside its derived set. ## Acceptance notes Sweep of `packages/platform-objects/src/**` for text that prescribes the unscoped grant, or that derives platform-admin standing from `admin_full_access`: - `identity/sys-user.object.ts` `role.description`: **changed here.** - `apps/translations/en.objects.generated.ts` `sys_user.role.help`: **regenerated here.** - `identity/sys-user.object.ts`, the `role` field's `readonly` comment (`platform admin: sys_user_permission_set / admin_full_access`): a source comment, not served. Left as is. It is true under `single` and incomplete under a wall. - `identity/sys-user.object.ts`, the objectstack-ai#9968 removal note (`Platform-admin membership is granted through sys_user_permission_set / admin_full_access`): a past-tense tombstone comment that the `objectstack-ai#15188` test pins by occurrence count. Left as is. - `platform-objects.test.ts`, the `objectstack-ai#9968` test's comment: history. Left as is. - `apps/translations/{zh-CN,ja-JP,es-ES}.objects.generated.ts` `sys_user.role.help`: these do **not** prescribe the grant. They still tell the reader to use the "Set Platform Role" action, which was retired in objectstack-ai#9968 (an older wording that the objectstack-ai#15188 change updated only in `en`). They are hand-written, legacy-trusted leaves with no source-hash entry, so neither `check:i18n` nor `check:i18n-stale-fill` reports them. Reported to the seat as a separate finding and not edited here: this card's file surface is the `en` text and its generated artefacts. Outside this package, listed and not edited: - `content/docs/permissions/authorization.mdx` ("either of which is sufficient") and `docs/qa/platform-checklist/areas/access-security.json` are carriers 1 and 2 of objectstack-ai#19145, which remains open for them. - `content/docs/permissions/permission-sets.mdx`, "Who holds `admin_full_access`". It says the unscoped grant row "still confers standing", "either one is sufficient", and that it "logs a pointer, once per process". That is false under a wall, and the pointer was deleted on every posture. **This page is not in objectstack-ai#19145's carrier list.** It is the same docs class, so it is routed to objectstack-ai#19145's scope and not edited here. - `packages/runtime/src/domains/automation.ts` `RUN_LIFECYCLE_DENY_MESSAGE` is carrier 3, split to the `domain:cli` lane. - `.changeset/sys-user-role-prose-retired-action.md` (pending, from PR objectstack-ai#17102) quotes the old sentence as what it changed the text to. It also says standing "comes from an unscoped `admin_full_access` grant", which was true when it landed. It is a history record of that PR and is left unedited. It will be compiled into the same release as this changeset, whose text supersedes it. - `packages/plugins/plugin-security/src/objects/sys-user-position.object.ts`, reserved-identity comment ("the unscoped `admin_full_access` grant for `platform_admin`, `bootstrapPlatformAdmin` writes a `sys_user_permission_set` row"): a source comment, incomplete under a wall, not served. No PR or person is currently set to pick it up. `origin/main` was merged into this branch once (3 commits: objectql, metadata and `scripts/pm/close-cards.mjs`, none in this package) so that the gate derivation read a current tree. `origin/main` has since moved by one docs-only commit. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…latform-admin standing under single only (objectstack-ai#19876) Part of objectstack-ai#19145 Clause-②: no Carriers 1 and 2 of objectstack-ai#19145 (the two documentation carriers), plus one more carrier of the same claim that the sweep found in the same tree. Carriers 3 (`packages/runtime/src/domains/automation.ts` `RUN_LIFECYCLE_DENY_MESSAGE`) and 4 (`packages/platform-objects/src/identity/sys-user.object.ts` `role.description`) are **not touched here**. They belong to other lanes, and a split has been requested from triage. objectstack-ai#19145 remains open for them. ## The rule as the code decides it (all at `2bbb4623`) - `packages/core/src/security/resolve-authz-context.ts:865`: `legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture())`. This reads the **requested** posture (the environment), not the effective one, so a walled deployment that is running degraded is still treated as walled. - `packages/spec/src/security/tenancy-posture.ts:54`: `postureEnforcesWall` returns `posture !== 'single'`. The walled postures are `group` and `isolated`. - `resolve-authz-context.ts:958`: an unscoped `admin_full_access` row sets `hasPlatformAdminGrant` only when `!legacyGrantAnchorRetired`, so only under `single`. The set's name and capabilities are still pushed on every posture, so the anchor is retired but the grant is not (pinned at `resolve-authz-context.platform-admin-config.test.ts:404`). - `resolve-authz-context.ts:1009`: the config anchor (`OS_PLATFORM_OWNER_EMAIL`, verified stored row) has no posture gate and confers on every posture. - The request path has no per-request pointer any more. `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are gone (absence pinned at `platform-admin.test.ts:267`, with positive controls). The only operator-facing line left is the boot-time fail-closed error at `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:897`, which names the legacy holder at `:912`. - The `single` half is described as it ships. Its fate is objectstack-ai#11979's (Choice 4B), and nothing here anticipates that decision. ## Per carrier: old text, new text, code site **1. `content/docs/permissions/authorization.mdx` (combination semantics, step 3)** - Old: "That posture is derived at one site from **two anchors**, either of which is sufficient: the configured administrator list … and an **unscoped `admin_full_access` grant** row." - New: The anchors depend on the tenancy posture. The configured list is sufficient under every posture. The unscoped grant row is sufficient only under `single`. Under `group` / `isolated` it is no longer an anchor: the set still grants what any held set grants, but it confers no `PLATFORM_ADMIN` standing, so standing comes from configuration alone. Adds a link to the tenancy-modes page. - Code: `resolve-authz-context.ts:865`, `:958`, `:1009`; `tenancy-posture.ts:54`. **1b. `content/docs/permissions/permission-sets.mdx` (who holds `admin_full_access`). The sweep found this; it was not in the card.** - Old: "derived at a single site from **two anchors**, and either one is sufficient" and, for the grant row, "It still confers standing, and it is **deprecated**: a deployment resolving through it logs a pointer, once per process, at the configuration line that re-anchors it." - New: Which anchor is sufficient depends on posture. The config anchor is read under every posture. The grant row confers standing under `single` only. Under walled postures it is not an anchor, and nothing is logged about it per request. If a walled deployment holding such a row has no usable configured administrator, the boot log reports zero administrators, names the account holding the row, and points at `OS_PLATFORM_OWNER_EMAIL`. - Code: as above, plus `bootstrap-platform-admin.ts:897` / `:912` for the boot line. **2. `docs/qa/platform-checklist/areas/access-security.json`, item `access-security.platform-owner-email-anchor`, revision 1 to 2 (history appended; revision 1 untouched)** - Step 6. Old: "grep the process log for the once-per-process legacy-grant re-anchor pointer". New: run the objectstack-ai#11663 L5 posture-split unit pins named in clause 6 and cite their output. The step states that there is no live log line to grep and that the only operator-facing line left is the boot-time fail-closed error. - Clause 6. Old: "the legacy anchor is honoured and loudly re-pointed … the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line … (reportLegacyPlatformAdminGrant, platform-admin.ts)", oracle `log`. New: the legacy grant is an anchor under `single` only, and a silent one. Under a walled posture (`postureEnforcesWall`, the requested posture) it confers no `PLATFORM_ADMIN` standing, although the held set keeps its capabilities. The config anchor still confers on that rig. The request path logs nothing. The oracle is now `test` (the L5 describes in `resolve-authz-context.platform-admin-config.test.ts` and `platform-admin.test.ts`), and the clause records that a change to the `single` half is objectstack-ai#11979's to make. - Source parenthetical. Old: "… matchesConfiguredPlatformAdmin verified-only, reportLegacyPlatformAdminGrant". New: "… resolvePlatformAdminEmails loud-once resolve, matchesConfiguredPlatformAdmin verified-only" (all live symbols). Adds two anchors: `resolve-authz-context.ts#legacyGrantAnchorRetired` and `tenancy-posture.ts#postureEnforcesWall`. - The same claim appeared elsewhere in the item, so these parts were fixed to keep the item true: - Step 1 booted `isolated` with the variable unset. That boot is refused today (`packages/plugins/plugin-auth/src/auth-plugin.ts:551`). - Under a walled posture the seeded admin holds no grant-anchored standing, because no row is written and an existing row confers nothing. That made clause 1, clause 3 and the seeded-admin both-sides control false under `isolated`. - The live legs now boot `single` (the zero-config default) and keep it across restarts. Clause 1, the persona and the knownGap name the posture. A walled variant was added. `automated.ref` now says clause 6 is test-only. - Code: as above, plus `auth-plugin.ts:551`. ## Verification (head `2505ca61`) - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 45 commands. All 45 were run and each exit code was recorded. All 45 exited 0. `check:skill-examples` first exited 3 (PREREQUISITE NOT MET: client-react not built). That is not a measurement. It exited 0 after `@objectstack/client-react` / `@objectstack/client` were built. `--ran` reconciles as 45 derived / 45 run / 0 unrun. - `pnpm check:platform-checklist`: OK. Symbol anchors went from 620/638 to 622/640 resolved. The `areas/access-security.json` anchor census went from **44 to 46** against its floor of 44, and the floor and ledger are unchanged. - The control-byte self-scan over the three files found 0 hits. - Not measured locally, and declared to CI: the Build Docs job, the Test Core shards and the type-check lanes that the derivation lists as CI-only. ## Changeset None. `content/docs/**` and `docs/qa/**` are not in any published package's `files[]` (checked over every non-private `package.json`), so nothing publishes. This is `skip-changeset` territory, and no label was written by this seat, per the dispatch. ## Acceptance notes - **Sweep** (`content/docs/**`, `docs/qa/**`, excluding the auto-generated `references/` and `releases/`) for `either of which`, `two anchors`, `once-per-process`, `reportLegacyPlatformAdminGrant`, `unscoped admin_full_access`, `re-anchor` and `admin_full_access`: - The only other carrier of the claim was `permission-sets.mdx:167-183`, fixed above. - `authorization.mdx` (validity-window and deactivation sections): "an expired / deactivated unscoped `admin_full_access` grant no longer derives `platform_admin`" is still true on every posture, so it was left alone. - `deployment/self-hosting.mdx` first-boot section: already posture-correct. - **File surface beyond the claim:** `content/docs/permissions/permission-sets.mdx` is outside the claim's declared file surface. It was fixed in place because it is the same defect class, a mechanical prose fix in the same tree, and no other claim holds it. The claim's surface needs to be widened by the seat. - **Step 1 of the checklist item** could not run as written, independently of objectstack-ai#19136, because an `isolated` boot with the variable unset is refused. It was fixed in the same edit because the item's other clauses depended on the boot posture. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18336
Clause-②: yes
Leg L5 of the accepted #11663 platform-admin re-anchor design — the EXIT of the migration window L4 opened in 17.3.0. Under a walled tenancy posture (
group/isolated), a legacy unscopedadmin_full_accessgrant row no longer confersPLATFORM_ADMIN, and the once-per-process deprecation pointer that announced this retires with it.The ruling this is built against, and the two constraints it did not touch
The maintainer ruled option B on 2026-09-16 (
issuecomment-5695555178): #11663's design governs over ADR-0131 D13 for these two symbols, and the card stays on the 17.x line. ADR-0131 was amended accordingly onorigin/mainbefore this branch was cut — verified here, not inherited: commit0b271e2e,docs/adr/0131-total-organization-ownership-no-null-organization-id.md, and the amended D5 states the scope in its own words:Two standing constraints the ruling did not touch, and which make the scope walled-only rather than all-postures:
(maintainer, 2026-09-08, #16682 comment
5587754690)(maintainer, 2026-09-16, the session that produced the ruling — the zero-config constraint)
⇒ The
singleposture keeps its zero-config first-user promotion and its grant row. Its disposition is #11979's (Choice 4B), out of scope here. The all-postures reading was measured on this card before dispatch and turns 11 pins red; this branch does not take it, and the pinan unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at allis green and kept deliberately.What changed
packages/core/src/security/resolve-authz-context.tshasPlatformAdminGrantis posture-keyed. A newlegacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()), hoisted once beside theunscopedUserPsIdsset, gates the one line that derives standing from an unscopedadmin_full_accessgrant.else if (hasPlatformAdminGrant)arm is removed. It carried one thing — the deprecation pointer — and the chain collapses to a bareif (configConfersPlatformAdmin).packages/core/src/security/platform-admin.ts/security/index.tsreportLegacyPlatformAdminGrantandresetLegacyPlatformAdminGrantReportare deleted, along with their once-per-process latch, and dropped from the re-export list.packages/plugins/plugin-security/src/bootstrap-platform-admin.tssys_userread that fed it are gone.error, naming the variable, the row and its holder.The boundary this card draws, stated because it is easy to read wider than it is
The retirement takes the anchor, not the row and not the grant. A walled holder keeps the
admin_full_accesspermission set they hold — its name and everything downstream resolves from that name — and loses only platform-admin standing: the rung, the built-inplatform_adminposition, and every consumer of those. Nothing here writes, deletes or re-owns a grant row; that row's ownership is ADR-0131 C3's, on the v18 line, and the amendment says the drop 「adds no NULL row, removes none, and re-owns none」.packages/specis untouched (this lane's standing red line).That boundary is pinned, not merely described:
the retirement takes the ANCHOR, not the GRANT — the held set still grants what it grants. If a later leg widens to the row, that pin is the one to re-author deliberately.Evidence
Clause-② confirmed on the rebuilt artefact, not on source alone (
turbo run build --filter=@objectstack/plugin-security, 18/18 tasks, exit 0):packages/core/distreportLegacy…resetLegacy…index.d.tsmatchesConfiguredPlatformAdmin4,setPlatformAdminConfigSink2name in await import('index.js')true,typeof functionnotAnExport_xyzfalse⇒ two symbols leave a published entry. Breaking shape, graded
minorunder the launch-window convention, on@objectstack/coreand@objectstack/plugin-security.Negative-pin dissolution. Every negative pin this branch relies on was dissolved: the forbidden behaviour was put back on disk (anchor hit verified, blob hash before/after recorded), the suite was watched go red, and the restore was proven by
blob == HEADand an emptygit diff HEAD— not by an exit code.&& !legacyGrantAnchorRetiredgateif (!unscopedHolder)expected "vi.fn()" to be called 1 times, but got 0 timesexport function reportLegacyPlatformAdminGranttoplatform-admin.tssecurity/index.tsThe last two are deliberately separate: each mutation reds exactly one of the two absence pins, which is what makes them two measurements rather than one restated twice.
⛔ Per the security family's disclosure discipline, the evidence above describes the consequence of a proposed mutation to this branch and carries no reproduction recipe for the shipped product.
A measurement defect found and fixed in the suite's own double. The pin
adds NO read: the recorded query multiset is identical under both answers of the gatefailed on first run, reporting asys_positionread whose$indiffered byplatform_admin. It was not a real difference: the resolver issues{ name: { $in: grants.positions } }— a live reference to an array §6c mutates after the query is issued — and the double storedopts.whereby reference, so the comparison read the array's final contents instead of the query as issued. The double now deep-copies at record time, and the two postures are measured byte-identical. The same latent shape in a sibling harness is reported below as an out-of-scope finding.Tests and gates.
pnpm typecheckgreen for@objectstack/coreand@objectstack/plugin-security(VERDICT command-exit 0). Targeted suites:resolve-authz-context.platform-admin-config.test.ts+platform-admin.test.ts56 passed,bootstrap-platform-admin-walled-owner.test.ts24 passed. Gate families were re-derived from the real diff withscripts/pm/dispatch-gates.mjs --commands(no paths passed) and reconciled with--ran.Acceptance notes
docs/qa/platform-checklist/areas/access-security.jsoncarries a clause describing the retired behaviour (「the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line」) and namesreportLegacyPlatformAdminGrantin an anchor's parenthetical. It is outside this dispatch's declared file surface, so it is not touched here; the anchor symbol itself isparsePlatformAdminEmails, which still resolves. Noted for whoever owns the checklist.PlatformAdminConfigSink.warnnow has no caller inside@objectstack/core. It is left in place deliberately: removing it would be a third published-surface removal, wider than this card, and it would break every consumer that passes an object literal with awarnmember.single-row NULL-ownership question the drop leaves open #18413 before this branch existed.Patch round 1 — the contract review's findings 1, 2 and 4
The at-tier review (
issuecomment-5737292414) returned FAIL on one blocking finding and two cheap ones. All three are addressed in this branch; nothing that PASSED was touched.Finding 1 (blocking) — the retirement was done at one of the anchor's TWO readers
packages/plugins/plugin-auth/src/last-admin-guard.tsenumerated platform administrators from the same unscoped, in-windowadmin_full_accessrow on every posture, while §6b had stopped deriving standing from it under a wall. One anchor, two readers, disagreeing — and the disagreement is introduced by this diff, so it is fixed here rather than sequenced.Grade 1 of
resolveAdminUserIdsis now keyed on the identical expression the derivation site uses (postureEnforcesWall(resolveTenancyPosture())), hoisted once per enumeration; under a wall the grade'ssys_permission_setscan is skipped outright, so that path also issues one read fewer.singleis untouched, and the two zero-population refusals gained a conditional clause namingOS_PLATFORM_OWNER_EMAIL, because 「restore theadmin_full_accessrow」 stopped being a remedy that ends the emptiness on a walled rig.Direction, stated because it reads backwards easily: under a wall the guard now refuses more (it protects the config-anchored administrator a write used to be allowed to take away) and refuses wrongly less (revoking the inert row is no longer reported as removing the last administrator). Both are narrowings toward the already-declared model.
Walled coverage was as much the finding as the logic.
last-admin-guard.config-anchor.test.tsgains a seven-case[#11663 L5]suite that drives both walled postures and pairs every walled arm with asinglecontrol on the identical fixture. The two posture-blind sibling suites (last-admin-guard.test.ts,last-admin-guard.re-pricing.test.ts) now pinsingleexplicitly instead of inheriting whatever the box exports, and the pre-L5 pin 「ALLOWS it when the same user also holds the legacy grant」 sayssinglein its title.Dissolution, both new pin families:
promise resolved "{ id: 'usr_owner', … email_verified: false }" instead of rejecting, i.e. the write that ends the last config-anchored standing goes through; the inverse arm fails with it. Everysinglecontrol stayed green.Finding 2 — the ② naming gap
Closed by naming, not by stating a precedent.
packages/runtime/src/index.ts:247andpackages/plugins/plugin-hono-server/src/adapter.ts:4areexport * from '@objectstack/core'(re-verified here; a repo-wide grep finds exactly those two), so the two removed names leave those published surfaces too. Both are now in the changeset, alongside@objectstack/plugin-auth, whose behaviour this round changes. The level is unaffected — all publishable packages sit in onefixedgroup — and the reason for naming them is the CHANGELOG tombstone an upgrading consumer of those packages greps. This follows theApiRegistryprecedent rather than the core-only one.Finding 4 — prose
admin-standing-surface.ts'ssys_user_permission_setreason is posture-qualified, and so is the module header's account of wherehasPlatformAdminGrantcomes from.check:doc-authoringrefused it — thatreasonis a runtime string that reaches customers, and the baseline is shrink-only. The sentence now says the same thing without them.Finding 3
Not touched here, by instruction — the seat filed it separately as #19145.
domain:servicesseat, ⛔ not by the delivering dev: the dev writes a PR body once at creation and never PATCHes it, so it handed the text over in its report. The seat verified the load-bearing claims against the tree before appending — the guard is posture-keyed atlast-admin-guard.ts:941(control onorigin/main: 0 occurrences) and the changeset names all five packages — and ⛔ did not verify every figure quoted above, which is the next reviewer's to test.Patch round 2 — the last unkeyed reader of the anchor
The at-tier review of
61a951c97(issuecomment-5737610351) returned FAIL on one blocking finding: the retirement was still done at only some of the anchor's readers. This round closes the class rather than one instance.The reader set, re-measured here and now closed
#11663's design P3 enumerates the in-repo consumers of 「who are the platform admins?」. Their state on this head, each verified in this branch rather than inherited:
resolve-authz-context.ts§6bplugin-security/src/bootstrap-platform-admin.tsplugin-auth/src/last-admin-guard.tsplugin-security/src/explain-engine.tsplugin-auth/src/ensure-default-organization.tsexplain-engine.tswas checked, not assumed: at:562it setshasPlatformAdminGrant: grants.posture === 'PLATFORM_ADMIN', reading the resolver's one verdict back, and its onlysys_user_permission_setread iscollectGrantProvenance, which reports why grants were dropped and derives no standing. It moved with §6b in round 0.An independent sweep says the set is closed. A grep for source reads of an unscoped
admin_full_accessgrant (organization_id: nullbeside the admin permission set, non-test) returns exactly two files in the whole tree —bootstrap-platform-admin.tsandensure-default-organization.ts— both now keyed. The remaining files that merely nameADMIN_FULL_ACCESSwere read: a config-validation refusal, the spec declaration, the seeded set definition, a CLI formatter, sandbox type assertions, and the two already-keyed derivation sites.What changed
ensureDefaultOrganizationstep 2 — its legacy fallback is keyed on the same expression the derivation uses. This reader is worse than the guard: the guard counted a holder, this one confers — it writes the account it picks asys_member { role: 'owner' }row on the Default Organization and hands them the org's seeded rows throughclaimSeedOwnership. Unkeyed, a walled rig carrying a legacy row bound, as an organization owner, an account the derivation gradesMEMBERand the bootstrap logs aterroras non-existent. Reachable despite plugin-auth skipping the helper under a wall (auth-plugin.ts:1100), because@objectstack/organizations— which a walled posture requires — wraps and calls it.Under a wall with no verified declared owner, the answer is now
no_admin: a wait, not a silent mis-binding. The declared owner's verifying update re-runs the helper through the trigger predicate, and the binding happens then.The trigger predicate's grant-insert arm retires with it under a wall — cost only, since the helper answered
no_adminfrom that grant either way. ⛔ Thesys_userarms are deliberately untouched: on a walled rig the declared owner's verifying update is the only write that ever grows the population, so keying that off would strand the deployment forever. The sibling predicateshouldReplayBootstrapForinplugin-securityalready spells the same walled exclusion.Coverage.
ensure-default-organization.test.tshad 0 posture-env sites; it now pinssinglefor the whole file instead of inheriting the ambient environment, and gains a seven-case[#11663 L5]walled suite in which every walled arm is paired with asinglecontrol on the identical fixture — including the zero-config default (both tenancy inputs unset), a no-added-read pin, and the config anchor still binding under a wall.Dissolution of the two new pin families:
ensure-default-organization.test.tsexpected { defaultOrgCreated: true, … } to match object { defaultOrgCreated: false, … }, i.e. the organization really is created and the legacy holder really is bound; the unverified-owner wait and the no-added-read pin fail with it. Everysinglecontrol stayed green.walled-default-org-self-registrant.pin.test.tsensure-default-organization.test.tsThe one declared out-of-lane change
packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.tspinned the pre-L5 model: its positive control asserted that underisolateda legacy grant still anchors the bootstrap, saying in its own words that the negative cases were 「green only because objectstack#11211 stopped minting that row on a walled box, not because this path is inert」. This diff makes that path inert, so the pin is inverted rather than deleted — the fixture is cloud#1509's reported shape and it is the only place in the tree that pins what a walled rig does with a legacy row at this seam. The suite's method survives: the operator case above it (a declared address reading verified) carries the positive-control role, and the header now records that clause 2 rests on the keying rather than on what gets minted.packages/plugins/organizationsis not in this lane's named package set. This is a deliberate, declared, test-only widening forced by CI — its runtime source is untouched.Changeset
@objectstack/organizationsis added atminor. ⛔ Not because a test was re-authored — that earns nothing. It is named because the behaviour an operator gets from that package moves: it is the runtime that performs the default-organization bootstrap on a walled deployment, and after this change it no longer creates a Default Organization for a legacy grant holder, nor binds them as its owner. The tombstone belongs in the CHANGELOG of the package whose behaviour changed, which is the same reasoning that named@objectstack/runtimeand@objectstack/plugin-hono-serverin round 1.One thing named rather than left to be found
plugin-approvals/src/approval-service.ts:1402decides its override actor withposture === 'PLATFORM_ADMIN' || perms.includes(ADMIN_FULL_ACCESS). Its first arm moved with §6b in round 0; its second reads the permission-set NAME, which a walled legacy holder still carries — the declared 「the retirement takes the ANCHOR, not the GRANT」 boundary this PR pins.single.domain:servicesseat, ⛔ not by the delivering dev, which writes a body once at creation and never PATCHes it. The seat independently re-ran the closure sweep with a different, cruder instrument (file-level co-occurrence of the token with anorganization_id: nullspelling) and reached the same conclusion by a different route: its extra hits —sys-user-position.object.ts,runtime/src/security/actor-user.ts,spec/src/identity/eval-user.zod.ts— were each read and are a refusal's explanatory comment, a docstring example, and the spec's own constant declaration. ⛔ None derives the population. The seat also verified the surface (16 files, nothing outside the authorised set, zeropackages/spec, theorganizationstouch test-only). The dissolution figures above are the dev's and are the next reviewer's to test.Generated by Claude Code