Skip to content

feat(security)!: retire the walled legacy platform-admin grant dual read and its deprecation log (#11663 L5) - #19136

Merged
huangyiirene merged 10 commits into
mainfrom
claude/issue-18336-retire-walled-legacy-grant-dual-read
Sep 19, 2026
Merged

huangyiirene merged 10 commits into
mainfrom
claude/issue-18336-retire-walled-legacy-grant-dual-read

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

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 unscoped admin_full_access grant row no longer confers PLATFORM_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 on origin/main before this branch was cut — verified here, not inherited: commit 0b271e2e, docs/adr/0131-total-organization-ownership-no-null-organization-id.md, and the amended D5 states the scope in its own words:

The legacy unscoped anchor retires with D13 for the single posture only: its walled half, together with the two symbols that carry the migration window — reportLegacyPlatformAdminGrant and resetLegacyPlatformAdminGrantReport — left D13 on 2026-09-16 and retire on the 17.x line under #11663's design instead (#18336).

Two standing constraints the ruling did not touch, and which make the scope walled-only rather than all-postures:

The rest of Choice 4A (#11974, 2026-08-25) stands: retiring the walled write must not retire the single one

(maintainer, 2026-09-08, #16682 comment 5587754690)

比如临时启动的开发环境,我不可能去配置啊

(maintainer, 2026-09-16, the session that produced the ruling — the zero-config constraint)

⇒ The single posture 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 pin an unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at all is green and kept deliberately.

What changed

packages/core/src/security/resolve-authz-context.ts

  • §6b: the legacy row-id route to hasPlatformAdminGrant is posture-keyed. A new legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()), hoisted once beside the unscopedUserPsIds set, gates the one line that derives standing from an unscoped admin_full_access grant.
  • The else if (hasPlatformAdminGrant) arm is removed. It carried one thing — the deprecation pointer — and the chain collapses to a bare if (configConfersPlatformAdmin).

packages/core/src/security/platform-admin.ts / security/index.ts

  • reportLegacyPlatformAdminGrant and resetLegacyPlatformAdminGrantReport are deleted, along with their once-per-process latch, and dropped from the re-export list.

packages/plugins/plugin-security/src/bootstrap-platform-admin.ts

  • The boot-side pointer call and the sys_user read that fed it are gone.
  • ⭐ The fail-closed backstop is no longer skipped when a legacy holder exists. L4 skipped it on the stated ground that 「the deployment HAS an administrator, on the old anchor」 and that the pointer carried the remedy instead. Both premises die with the walled dual read: such a rig now has zero platform administrators. It is told, at 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_access permission set they hold — its name and everything downstream resolves from that name — and loses only platform-admin standing: the rung, the built-in platform_admin position, 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/spec is 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):

probe on packages/core/dist reportLegacy… resetLegacy… positive control negative control
occurrences in index.d.ts 0 0 matchesConfiguredPlatformAdmin 4, setPlatformAdminConfigSink 2 —
name in await import('index.js') false false both true, typeof function notAnExport_xyz false

⇒ two symbols leave a published entry. Breaking shape, graded minor under the launch-window convention, on @objectstack/core and @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 == HEAD and an empty git diff HEAD — not by an exit code.

pin family mutation result
walled rigs derive nothing from the legacy row delete the && !legacyGrantAnchorRetired gate 5 failed / 26 passed — the two walled-standing arms, the anchor-vs-grant boundary pin, the seeded-read arm, and the fourth-corner pin
the boot fail-closed line is no longer skipped re-wrap the emission in if (!unscopedHolder) 1 failed / 23 passed — expected "vi.fn()" to be called 1 times, but got 0 times
the symbol is gone from the module add a stub export function reportLegacyPlatformAdminGrant to platform-admin.ts 1 failed / 24 passed — the module pin only; the entry pin stayed green
the symbol is gone from the published entry add the export to security/index.ts 1 failed / 24 passed — the entry pin only; the module pin stayed green

The 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 gate failed on first run, reporting a sys_position read whose $in differed by platform_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 stored opts.where by 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 typecheck green for @objectstack/core and @objectstack/plugin-security (VERDICT command-exit 0). Targeted suites: resolve-authz-context.platform-admin-config.test.ts + platform-admin.test.ts 56 passed, bootstrap-platform-admin-walled-owner.test.ts 24 passed. Gate families were re-derived from the real diff with scripts/pm/dispatch-gates.mjs --commands (no paths passed) and reconciled with --ran.

Acceptance notes

  • docs/qa/platform-checklist/areas/access-security.json carries a clause describing the retired behaviour (「the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line」) and names reportLegacyPlatformAdminGrant in an anchor's parenthetical. It is outside this dispatch's declared file surface, so it is not touched here; the anchor symbol itself is parsePlatformAdminEmails, which still resolves. Noted for whoever owns the checklist.
  • PlatformAdminConfigSink.warn now 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 a warn member.
  • ADR-0131's own text is untouched — it was amended for this leg by Amend ADR-0131 D13 to drop the two platform-admin reporter symbols (maintainer ruling B on #18336) — and close the 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.ts enumerated platform administrators from the same unscoped, in-window admin_full_access row 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 resolveAdminUserIds is now keyed on the identical expression the derivation site uses (postureEnforcesWall(resolveTenancyPosture())), hoisted once per enumeration; under a wall the grade's sys_permission_set scan is skipped outright, so that path also issues one read fewer. single is untouched, and the two zero-population refusals gained a conditional clause naming OS_PLATFORM_OWNER_EMAIL, because 「restore the admin_full_access row」 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.ts gains a seven-case [#11663 L5] suite that drives both walled postures and pairs every walled arm with a single control on the identical fixture. The two posture-blind sibling suites (last-admin-guard.test.ts, last-admin-guard.re-pricing.test.ts) now pin single explicitly instead of inheriting whatever the box exports, and the pre-L5 pin 「ALLOWS it when the same user also holds the legacy grant」 says single in its title.

Dissolution, both new pin families:

mutation result
remove the grade-1 posture gate 2 failed / 16 passed — the blocking arm fails with 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. Every single control stayed green.
force the walled remedy clause empty 1 failed / 17 passed — only the remedy arm; the refusal itself fired unchanged, which is what makes the clause additive and conditional rather than a new verdict.

Finding 2 — the ② naming gap

Closed by naming, not by stating a precedent. packages/runtime/src/index.ts:247 and packages/plugins/plugin-hono-server/src/adapter.ts:4 are export * 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 one fixed group — and the reason for naming them is the CHANGELOG tombstone an upgrading consumer of those packages greps. This follows the ApiRegistry precedent rather than the core-only one.

Finding 4 — prose

admin-standing-surface.ts's sys_user_permission_set reason is posture-qualified, and so is the module header's account of where hasPlatformAdminGrant comes from. ⚠️ The first attempt cited the tracker ids and check:doc-authoring refused it — that reason is 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.

⚠️ This section was appended by the domain:services seat, ⛔ 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 at last-admin-guard.ts:941 (control on origin/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:

reader how it answers state
resolve-authz-context.ts §6b derives keyed (round 0)
plugin-security/src/bootstrap-platform-admin.ts reads the row at boot keyed (round 0)
plugin-auth/src/last-admin-guard.ts counts the population keyed (round 1)
plugin-security/src/explain-engine.ts consumes the verdict ✅ needs nothing
plugin-auth/src/ensure-default-organization.ts confers on the population keyed (this round)

explain-engine.ts was checked, not assumed: at :562 it sets hasPlatformAdminGrant: grants.posture === 'PLATFORM_ADMIN', reading the resolver's one verdict back, and its only sys_user_permission_set read is collectGrantProvenance, 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_access grant (organization_id: null beside the admin permission set, non-test) returns exactly two files in the whole tree — bootstrap-platform-admin.ts and ensure-default-organization.ts — both now keyed. The remaining files that merely name ADMIN_FULL_ACCESS were 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

ensureDefaultOrganization step 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 a sys_member { role: 'owner' } row on the Default Organization and hands them the org's seeded rows through claimSeedOwnership. Unkeyed, a walled rig carrying a legacy row bound, as an organization owner, an account the derivation grades MEMBER and the bootstrap logs at error as 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_admin from that grant either way. ⛔ The sys_user arms 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 predicate shouldReplayBootstrapFor in plugin-security already spells the same walled exclusion.

Coverage. ensure-default-organization.test.ts had 0 posture-env sites; it now pins single for 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 a single control 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:

mutation suite result
remove the step-2 posture gate ensure-default-organization.test.ts 3 failed / 38 passed — the walled binding arm fails with expected { 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. Every single control stayed green.
the same mutation walled-default-org-self-registrant.pin.test.ts 1 failed / 4 passed — the re-authored pin, and only it. The surviving positive control stayed green, so the inverted assertion is falsifiable rather than vacuous.
delete the trigger's walled exclusion ensure-default-organization.test.ts 1 failed / 40 passed — only the trigger arm; the helper's own answers were untouched, which is what makes it a cost fix and not a verdict change.

The one declared out-of-lane change

packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts pinned the pre-L5 model: its positive control asserted that under isolated a 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/organizations is 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/organizations is added at minor. ⛔ 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/runtime and @objectstack/plugin-hono-server in round 1.

One thing named rather than left to be found

plugin-approvals/src/approval-service.ts:1402 decides its override actor with posture === '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. ⚠️ Measured: this diff does not change that function's answer for such an account (before, both arms were true; after, the second still is), so it is not a dual-track this PR opens and it is not fixed here. It belongs to whatever leg disposes of the row itself — ADR-0131 C3 on the v18 line, and #11979 for single.

⚠️ Appended by the domain:services seat, ⛔ 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 an organization_id: null spelling) 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, zero packages/spec, the organizations touch test-only). The dissolution figures above are the dev's and are the next reviewer's to test.


Generated by Claude Code

… 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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/approvals.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/data-modeling/objects.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE))
  • content/docs/deployment/environment-variables.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE))
  • content/docs/deployment/self-hosting.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/permissions/authorization.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE), admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/permissions/delegated-administration.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE))
  • content/docs/permissions/permission-sets.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE), admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/permissions/permissions-matrix.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/permissions/sharing-rules.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/ui/audience-based-interfaces.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))

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

  • content/docs/releases/implementation-status.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE))
  • content/docs/releases/v13.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE))
  • content/docs/releases/v14.mdx (via sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE), admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/releases/v16.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE), admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/releases/v17/17-0.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/releases/v17/17-1.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (symbol, a field of const object ADMIN_STANDING_SURFACE), admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/releases/v17/17-2.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))
  • content/docs/releases/v17/17-3.mdx (via admin_full_access (literal, a string literal in bootstrapPlatformAdmin; a string literal in reportLegacyPlatformAdminGrant))

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

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

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

Which tree this was computed on

This run read content/docs from d93878c5d29f897f3b1604e008b846aec064fd22 — the merge of head e632839ce6f53ac2d6d8faa911cf266f7add57ca into base 1047fe10166c943807d61218e2fde9c3ab22e502, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4da89b8eae37cd6a71817c78f61a866d65c99d52

Isolated at-tier review. Judged against the diff at that head (8 files, 3 commits), the card's own comments (the option-B ruling 5695555178, the unlock 5731154376), and the governing text re-read on origin/main at aadea24b8 — ADR-0131 D5 plus its 2026-09-17 amendment, #11663 design comment 5394453215 (P5, Choice 4, §5 steps 6–7, row L5), #16682 comment 5587754690 (Choice 4A). The PR body and the dev report were read but nothing below is taken from them.

① Derived judgments

Every change to the accept/reject set and to the published surface that the diff produces, one by one:

  1. Walled derivation narrowed (correct). resolve-authz-context.ts §6b: the one line that sets hasPlatformAdminGrant from an unscoped admin_full_access grant gains and not legacyGrantAnchorRetired, where legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()). Read on origin/main, not from the body: postureEnforcesWall(p) is p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53); resolveTenancyPosture() returns OS_TENANCY_POSTURE when set (throwing on an invalid value), else isolated when OS_MULTI_ORG_ENABLED is truthy, else single (packages/types/src/env.ts:147). So on group/isolated — including a rig that is walled only through the legacy multi-org flag — the row stops conferring the PLATFORM_ADMIN rung, the built-in platform_admin position (§6c reads hasPlatformAdminGrant) and the rung consumers. The set's own name and capabilities are still pushed onto grants.permissions on every posture, so the change is to standing only. That is exactly the scope the ruling and the amended D5 name.
  2. The else if (hasPlatformAdminGrant) arm removed (correct, no accept/reject effect). Its body was solely the posture-keyed pointer; under single it was already a no-op ([finding] The #11663 P5 legacy-grant deprecation pointer fires on single-posture rigs, where ruled Choice 4A says the row is NOT going away #13667), so the removal changes no derivation output on any posture. hasPlatformAdminGrant = true is still set in the config branch; §6c and §6d are untouched.
  3. single is genuinely untouched — derived from the diff, not asserted. Under single the gate evaluates false and the §6b line is behaviour-identical to base; the removed arm was inert there; hasPlatformAdminStanding (resolve-authz-context.ts:1127) is a projection of resolveUserAuthzGrants reading the rung, so plugin-auth's session payload, impersonation and sso-register gates (auth-manager.ts isPlatformAdminUserId) move with the derivation by construction and cannot fork per posture. Boot side: the four hunks in bootstrap-platform-admin.ts are the header, the import block and the walled branch; the single early return (not walled and unscopedHolder ⇒ already_have_admin) and the single promotion are untouched. One caution the diff introduces: resolveTenancyPosture() is now read once per resolveUserAuthzGrants call instead of only inside the retired arm; it is an environment read (the branch's own multiset pin measures zero added queries) and the same value is already validated at boot, so I do not count it.
  4. Boot path (log-only). The sys_user read that fed the pointer is gone (one fewer boot read for a walled legacy holder); the undeclared-config error line is no longer skipped when a legacy holder exists and gains a conditional sentence naming the row and the holder's user id. reason values and the return shape are unchanged. Judged in ③.
  5. Two names leave the published entry (correct, and verified on the built artefact). packages/core/package.json exports has exactly . and ./logger; src/index.ts:44 is export * from ./security/index.js; security/index.ts drops both names. Across the whole PR-head tree the two names occur in zero non-comment source lines (the hits are comments and the two absence pins; positive control on the same grep: matchesConfiguredPlatformAdmin resolves at its definition and its re-export). Built artefact: I extracted the head with git archive into an isolated scratch copy (the shared checkout was never touched), ran an offline install and turbo run build --filter=@objectstack/core (4 tasks, exit 0), and read packages/core/dist: index.d.ts / index.js / index.cjs contain reportLegacyPlatformAdminGrant 0 / 0 / 0 and resetLegacyPlatformAdminGrantReport 0 / 0 / 0, with positive controls on the same files matchesConfiguredPlatformAdmin 4 / 3 / 4 and setPlatformAdminConfigSink 2 / 2 / 3, and negative control notAnExport_xyz 0 / 0 / 0. A runtime probe of dist/index.js answers false for both removed names, true (function) for matchesConfiguredPlatformAdmin, setPlatformAdminConfigSink and hasPlatformAdminStanding, false for the negative control. CI Build Core and Type Check · workspace are success on this head, so no in-repo TypeScript importer survives.
  6. The same two names also leave two more published entries — undeclared. packages/runtime/src/index.ts:247 and packages/plugins/plugin-hono-server/src/adapter.ts:4 are export * from '@objectstack/core' (hono's src/index.ts:5 re-exports the adapter). Both packages are non-private and their built dist/index.d.ts carry that export * statement verbatim on line 2 (read on a sibling built tree at 19dda8c94, which predates this branch; on that instrument the name grep returns 0 for the removed names and 0 for the kept control alike, which is what a re-export statement looks like, so the statement is the evidence, not the count). Consequence: @objectstack/runtime and @objectstack/plugin-hono-server lose the two names from their published surface too. Carried to ②.
  7. Widening: none. No new export, no new key on any published payload (the bootstrap return shape is unchanged; a longer log string is not a key), no newly accepted input. The only additions are a module-private const, comments, and tests.
  8. Test double (structuredClone of the recorded where) is test-only; the committed batch-equivalence golden is not in the diff. Correct.

🔴 Finding — the walled retirement is done at one of the two readers of the same anchor. packages/plugins/plugin-auth/src/last-admin-guard.ts enumerates platform administrators from the same unscoped, in-window admin_full_access grant row (grade 1, its lines ≈900–950) with no posture read anywhere in the file (0 occurrences of postureEnforcesWall / resolveTenancyPosture; control on the same instrument: auth-plugin.ts has 7). Its own header binds it to the derivation: 「the same evidence resolveAuthzContext derives platform_admin from」, 「the enumeration must answer the SAME question resolveAuthzContext does」; design P6 says the admin-standing surface moves with the derivation, and L2 is the precedent — it added the config anchor to §6b and to this guard's grade 3 in the same leg. After this diff, on a walled rig that still holds a legacy row (the row is deliberately left in place and pinned so), the guard counts a holder the derivation no longer recognises. Consequence, stated abstractly: a write that ends the last config-anchored administrator's standing is permitted because the guard believes an administrator remains, and the deployment is then without a platform administrator — the outcome the guard exists to refuse; conversely a write that removes the now-inert row is refused with a message claiming it would remove the last administrator. The guard's own pin last-admin-guard.config-anchor.test.ts:257 (「ALLOWS it when the same user also holds the legacy grant」) encodes the pre-L5 model, and no guard test runs under a walled posture (0 hits for OS_TENANCY_POSTURE / OS_MULTI_ORG_ENABLED across last-admin-guard*.test.ts; control: bootstrap-platform-admin-walled-owner.test.ts has 21), so CI green here measures single only. The PR body, the report's deviations and its out-of-scope findings do not mention the guard. This is a derivation/enumeration dual-track opened on walled rigs by the diff — the class the migration window was built to close — and it is undeclared. Recoverable by a configuration change, which is why it is one finding and not a lockout without remedy; it is still a finding. Lesser, same family: packages/core/src/security/admin-standing-surface.ts:130–135 (inside the touched surface) still declares the unscoped grant as 「the grant that makes a user a platform admin」 with no posture qualifier; the gate compares that declaration's column set, which is unchanged, so the gate is right and the reason prose is what is now stale.

② Semver level

Convention read from scripts/check-changeset-no-major.mjs on origin/main, not assumed: during the launch window a breaking change ships minor, never major; the bump level is explicitly not the carrier — the BREAKING banner in the changeset body and an ADR-0087 disposition marker are; and a Clause-② PR must grade at least one package whose published source it moves minor or above. The changeset grades @objectstack/core and @objectstack/plugin-security minor, opens with a BREAKING banner, and carries exactly one adr-0087: not-required (no-migration-prescription) marker whose category is right (no Zod schema, no packages/spec declaration, no stored representation; the compiler and the boot log are the reaching channels). The Clause-② line lives on the PR body and the card's claim comment, which is where check-clause2-carriers reads it; 0 of the ≈40 breaking-shaped changesets on origin/main carry one in the body, so its absence from the changeset is the convention, not a miss. Level: correct on both packages named. @objectstack/plugin-security's own change is behavioural (boot log), with bootstrapPlatformAdmin's return contract unchanged; minor under the same banner is the right grade.

Naming: incomplete on a strict reading. Per ① item 6 the removed names also leave @objectstack/runtime and @objectstack/plugin-hono-server. All 70 publishable packages sit in one fixed group, so the bump is identical whatever is named and the level is unaffected — but during the window the changeset is also the CHANGELOG tombstone an upgrading consumer greps, and neither of those packages' CHANGELOG will carry it. Precedent is mixed: PluginSecurityScanner's removal was declared on core only (core CHANGELOG 4 hits, runtime 0), ApiRegistry's named all three. Recorded as a gap to close cheaply (add the two packages, or state in the changeset why they are not named); not the verdict driver.

③ Boundary flags

  • Fail-closed boot line no longer skipped — mechanically forced, not scope creep. L4's skip rested on two premises the diff itself removes: the row confers, and the pointer carries the remedy. With the pointer gone and the row inert under a wall, the unskipped error line is the only word such a rig gets on the paths that reach the bootstrap without plugin-auth's init refusal (auth-plugin.ts:550 still refuses boot for walled + undeclared, so the backstop matters for os meta resync and embeddings). Leaving the skip would ship the silent half of a fail-closed guard. The added holder sentence is conditional (pinned with a control) and names a user id at error level, which the retired pointer already did. Correct call.
  • PlatformAdminConfigSink.warn left in place — correct call. The interface is published; dropping warn narrows a published type and breaks every object-literal caller, a third removal the card did not authorise. Its TSDoc (「Sink for the refusal notice」) remains accurate for error. Noted in acceptance notes, which is what Prime Directive chore: version packages #10 asks for something that is neither a defect nor a contract violation.
  • ADR-0131 and packages/spec untouched — correct. The D5 text on origin/main says what the diff does (walled half and both symbols on 17.x; the row's ownership unchanged, C3/v18). The diff writes, deletes and re-owns no row; that boundary is pinned.
  • docs/qa/platform-checklist/areas/access-security.json:2462 and :2492 — the note is not enough. The clause instructs a runner, with a log oracle, to expect a pointer the runtime no longer emits on any posture and standing a walled rig no longer confers; the anchor parenthetical names a deleted symbol. Outside the dispatch surface, but a checklist item that now specifies retired behaviour is a contract-text drift a checklist-test run will red on, and 「noted, not filed」 leaves no carrier. Same for content/docs/permissions/authorization.mdx:113–118, which states that PLATFORM_ADMIN derives from 「two anchors, either of which is sufficient」 with no posture qualifier — true under single, false under a wall after this diff (the docs-drift bot flagged the page). Both need a filed card (the L7 docs leg is the natural home). Not verdict-driving alone.
  • Not flagged by the author, and should have been: the last-admin-guard sibling reader (① finding) and the two re-export republishers (②).

Verdict reasoning. The diff does what the ruling authorises, keeps single intact — verified, not assumed — and removes the two symbols cleanly from the built artefact with controls. It fails on one thing: it retires the walled anchor at the derivation while leaving the lockout guard counting that anchor on the same rigs, undeclared. Two routes to green, the owning seat's choice: (a) posture-key the guard's grade-1 enumeration the same way §6b is keyed (plugin-auth already imports both helpers) and pin it under a walled posture in both directions; or (b) if the guard is ruled its own sequenced leg, say so in the PR body and file the card before landing. Alongside, add @objectstack/runtime and @objectstack/plugin-hono-server to the changeset (or state why not), and file the checklist/docs drift.

Implemented-by: claude/issue-18336-retire-walled-legacy-grant-dual-read
Reviewed-by: session_019hBqDVrwbijUCoK9qsss2E

VERDICT: FAIL


Generated by Claude Code

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
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 18, 2026
…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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 61a951c97120b04c02e5ac34b268102e6b7efd35

Isolated at-tier review of the new head after patch round 1. Judged against the diff of that head over its merge base aadea24b8 (13 files, 8 commits including the origin/main merge 30fb36639), the card's own comments (option-B ruling 5695555178, unlock 5731154376, the REWORK handover 5737313226), the previous review record 5737292414, and the governing text re-read on origin/main — ADR-0131 D5, its D14 exception and the 2026-09-17 amendment; #11663 design comment 5394453215 (H4, P3, P5, P6, Choice 4, §5 steps 5–6, rows L3/L5); #16682 comment 5587754690 (Choice 4A). The PR body's patch-round section and both dev reports were read; nothing below is taken from them. CI on this head: 34 check names, every latest run per name is success or skipped (Lint & Repo Gates completed success 23:53:55Z, all six Test Core shards success, Build Core, TypeScript Type Check, Dogfood Regression Gate, Temporal Conformance, Governed Surface Queue Guard success).

① Derived judgments

  1. Round 0 is intact. The seven round-0 files are byte-identical between the post-merge commit 30fb36639 and the head (empty git diff --stat; control on the same instrument: the same command over .changeset/ lists 1 file, 21 insertions), and the merge brought nothing into packages/core/src/security, plugin-security/src or plugin-auth/src (empty diff between 4da89b8ea and 30fb36639 on those paths while 5 commits landed on main past the base). So the previous review's ① items 1, 2, 4, 5, 7 and 8 hold on this head unchanged: the walled §6b line is narrowed by and not legacyGrantAnchorRetired; the else if arm is gone with no derivation effect; both names leave core's . entry — at head the two names have 0 non-comment source hits anywhere in the tree (the only hits are the two absence pins, ADR-0131's prose and the checklist JSON; control: matchesConfiguredPlatformAdmin 1 in security/index.ts, 5 in platform-admin.ts), and Build Core plus TypeScript Type Check are success on this head, so no in-repo importer survives.

  2. ⭐ single is still genuinely untouched — re-derived, not inherited. postureEnforcesWall(p) is p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53); resolveTenancyPosture() answers single when OS_TENANCY_POSTURE is unset and OS_MULTI_ORG_ENABLED is not truthy (packages/types/src/env.ts:147). Under single both legacyGrantAnchorRetired sites evaluate false: the §6b line is behaviour-identical to base and the guard's grade-1 scan runs exactly as before. Both sides pin it with a single control on the identical fixture — core's fourth-corner pin asserts single PLATFORM_ADMIN and isolated MEMBER in one test and the default-posture arm (both variables unset) separately; the guard's "ALLOWS ... under single" and "single still REFUSES that same revocation" arms do the same. Nothing in the patch round reaches the single promotion or its row.

  3. The previous blocking finding is closed at the guard, and closed with the same predicate. last-admin-guard.ts:941 hoists postureEnforcesWall(resolveTenancyPosture()) once per enumeration and, under a wall, skips the grade-1 sys_permission_set scan outright, so adminSetIds is empty and the grant scan never runs; grades 2 (org owner/admin) and 3 (config-anchored, via the imported matchesConfiguredPlatformAdmin) are untouched. Predicate identity: the same expression through the same two shared helpers, imported from the same modules core imports them from (postureEnforcesWall from @objectstack/spec/security, resolveTenancyPosture from @objectstack/types), reading the REQUESTED posture on both sides — a shared implementation, not a re-implementation. It is not one named predicate: the expression is now spelled at four sites (§6b, the guard's grade 1, the guard's remedy clause, bootstrap's walled). Binding them to one export would be a new published symbol the card does not authorise, so the duplicated expression is the correct narrow call and the residual drift risk is accepted rather than hidden. Accept/reject change under a wall, derived from the code: (a) a write that would end the last config-anchored administrator's standing while a legacy row exists is now REFUSED where it was permitted; (b) revoking the now-inert row, or deleting its holder, is now PERMITTED where it was refused as removing the last administrator. Both follow the derivation. Coverage: the seven-case L5 suite drives group and isolated and pairs every walled arm with a single control; posture reads in the guard 0 on origin/main (control 7 in auth-plugin.ts) and 6 at head.

  4. 🔴 BLOCKING — the retirement is still done at only some of the anchor's readers. Third reader: packages/plugins/plugin-auth/src/ensure-default-organization.ts, INSIDE the surface the REWORK widened (packages/plugins/plugin-auth/src/). Its step 2 (lines 292–310 at head) answers the population question "which user is the platform admin?" from the oldest unscoped admin_full_access grant, on every posture: 0 posture reads in the file (same grep, controls last-admin-guard.ts 6, auth-plugin.ts 7). It is the first of the four "who are the platform admins?" consumers [Design] Re-anchor platform-admin: admin_full_access becomes a kernel metadata declaration; WHO holds it comes from env-configured verified emails — retiring the org-less row anchor #11663's design P3 enumerates (ensure-default-organization.ts:110, last-admin-guard.ts:764, bootstrap-platform-admin.ts already_have_admin, explain-engine.ts): this head keys the bootstrap and the guard, explain reads the resolver's verdict back (hasPlatformAdminGrant: grants.posture === 'PLATFORM_ADMIN'), and this one is left. The file's own header and step-2 comment, read on origin/main and not from either document, name this leg as the retirement point: 「It is removed with the legacy-grant removal leg (design §5 step 6), not here」 — under the walled-only ruling that means keyed off under a wall exactly as the guard was, not deleted.

    Reachability under a wall: plugin-auth's own wiring skips the helper under a wall (auth-plugin.ts:1100), but the in-repo @objectstack/organizations plugin — which every walled posture requires (ADR-0105 D12) — wraps and calls it (organizations/src/ensure-default-organization.ts:40; wiring organizations-plugin.ts:461–489, option default true) on kernel:ready and after every write matched by isDefaultOrganizationBootstrapTrigger. Consequence, stated abstractly: on a walled rig that still carries a legacy row — the rig this card is about — in the window before the declared owner's account exists and reads verified, the helper's config anchor answers nothing, its legacy fallback selects the legacy holder as "the platform admin", and it WRITES that account a sys_member row with role owner on the Default Organization and hands them the seeded rows through claimOrgSeedOwnership — while §6b on the same rig grades that account MEMBER and bootstrapPlatformAdmin logs at error that the deployment has zero platform administrators. One anchor, two readers, opposite answers, in one boot; and this reader does not merely count, it confers. The disagreement is introduced by this diff, which is the ground the seat itself gave for refusing to sequence the guard fix (5737313226).

    The tree at head PINS the pre-L5 model on this path: packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts:276 ("POSITIVE CONTROL — a legacy cross-tenant grant still anchors the bootstrap") runs under isolated with the owner declared but unregistered, seeds a self-registrant holding the unscoped grant, and asserts the walled wiring creates the Default Organization and binds her as owner — green on this head. The Walled postures must not elevate the first self-registrant: platform admin comes ONLY from an env-declared owner email; self-signups never join the Default Organization — ruled fix for cloud#1509 #11184 clause-2 premise that suite rests on ("the helper is ADMIN-KEYED") stops holding for legacy rows the moment this diff lands. No ensure-default-organization test runs walled (0 posture-env sites in ensure-default-organization.test.ts; control 8 in last-admin-guard.config-anchor.test.ts). Neither platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback #11979 nor platform-admin re-anchor L5 (migration): time-boxed legacy-grant dual read, then its removal one minor later #11975 names this reader (both bodies read: no mention). Same family, lesser: the trigger predicate's sys_user_permission_set-insert arm, which the same file says retires with this leg, now over-fires under a wall — cost only, the helper is idempotent — and belongs with the fix.

    Two green routes, the owning seat's choice, noting its own recorded ruling on the identical question excluded the second: (a) key step 2 on the same expression, in the widened surface, with walled arms and a single control in ensure-default-organization.test.ts; re-author the organizations positive-control pin — outside the widened surface, so widen deliberately — to assert the post-L5 answer (no_admin, no bind, until the declared owner verifies); and, since that changes @objectstack/organizations' walled bootstrap behaviour, name it in the changeset. (b) Declare it a sequenced leg, file the card before landing, and state in the PR body that walled rigs ship with this reader disagreeing.

  5. Widening: none on this head. The diff adds no export (0 added export declarations in the diff — the one matching + line is changeset prose; 2 removed, the two names in security/index.ts); plugin-auth's entry is unchanged; no new key on any payload (the guard's refusals grow a conditional sentence, not a field; bootstrapPlatformAdmin's return shape is unchanged); no newly accepted input. The two new imports into plugin-auth resolve to declared dependencies (@objectstack/spec and @objectstack/types are both in its package.json).

  6. The un-skipped boot line, its holder clause and the deep-copying test double are byte-identical to round 0, were passed there, and are not re-litigated.

② Semver level

Level: correct. Convention read from scripts/check-changeset-no-major.mjs on origin/main, not assumed: during the launch window a breaking change ships minor, never major, and a Clause-② PR grades at least one package whose published source it moves minor or above. The changeset grades five packages minor, opens with the BREAKING banner, and carries exactly one ADR-0087 marker, not-required (no-migration-prescription), whose category is right (no Zod schema, no packages/spec declaration, no stored representation; the reaching channels are the compiler and the boot log). All five sit in the one 70-member fixed group (.changeset/config.json), so the bump is identical whatever is named.

Naming: correct and not over-named for what this diff does. @objectstack/core loses two names from .; @objectstack/plugin-security changes boot behaviour; @objectstack/plugin-auth changes the guard's refusal set. @objectstack/runtime and @objectstack/plugin-hono-server are the only wholesale re-exporters of core in the tree — a quote-agnostic grep for a star re-export of core, runtime or hono-server across packages/ finds exactly runtime/src/index.ts:247 and plugin-hono-server/src/adapter.ts:4, and nothing re-exports those two wholesale — both non-private with a . entry, and their built .d.ts carry that statement, so the two names leave their surfaces too and the tombstone belongs in their CHANGELOGs; the ApiRegistry precedent is stated in the changeset. Incomplete only conditionally: if ①4 is closed by route (a), @objectstack/organizations (non-private, in the fixed group, its walled default-org bootstrap behaviour changed through the helper it wraps) must be added. Leaving PlatformAdminConfigSink.warn in place means there is no third removal to declare.

③ Boundary flags

  • PlatformAdminConfigSink.warn left in place though its last in-package caller is gone — correct. At head sink.warn( has 0 call sites in platform-admin.ts (control sink.error( 1); the interface is published from core's entry; dropping the member narrows a published type and breaks every object-literal caller — a third removal the card does not authorise. The acceptance-note placement is what Prime Directive chore: version packages #10 asks for something that is neither a defect nor a contract violation.
  • The check:doc-authoring refusal and the workaround — sound. The new sys_user_permission_set reason string at head (admin-standing-surface.ts:133–141) carries no tracker id — the file's id hits are lines 7, 20, 23, 59, 103, 113, 115, 179 and 235, i.e. comments plus pre-existing, already-pinned runtime strings — and it still says what it must: an unscoped in-window grant confers under a non-walled posture; under group/isolated it confers nothing and the row is read only for the set it names; single is unchanged. scripts/doc-authoring-prose-id.baseline.json is byte-identical between the merge base and the head (empty git diff --stat; control on the same instrument: .changeset/ shows 1 file, 21 insertions), so no baseline was grown. The table's columns are unchanged, so the P6 correspondence test is unaffected.
  • The two conditional refusal-remedy clauses — mechanically forced, not creep. Both refuseIfEmptiedRatherThanFresh refusals name one remedy, restore or re-activate the admin_full_access row; before this diff that remedy restored standing on a walled rig and after it it does not, so the false remedy is introduced here. Message-only, additive, conditional, and pinned with a single control proving the clause is absent there. No verdict changes.
  • Pinning single in last-admin-guard.test.ts and last-admin-guard.re-pricing.test.ts — forced, test-only, not creep. The guard's answer became posture-dependent in this diff and those suites read the environment without setting it; the walled half is measured in the config-anchor suite.
  • The un-skipped fail-closed boot line, ADR-0131 and packages/spec untouched — unchanged from round 0 and not re-judged.
  • Finding 3's carrier exists: [finding] the platform-admin derivation becomes posture-dependent when #18336 lands, and two shipped documents still say it is not — authorization.mdx's "either of which is sufficient" and three access-security checklist lines naming the retired pointer #19145 is open, labelled finding, Blocked-by: #18336. Two more strings of the same prose family, outside the surface, belong on that card: packages/runtime/src/domains/automation.ts:615 and packages/platform-objects/src/identity/sys-user.object.ts:618 (with its generated translation) still say platform standing comes from the unscoped admin_full_access grant, with no posture qualifier.
  • Not flagged by the author and should have been: the default-organization reader (①4) and the organizations pin that asserts the pre-L5 walled model.

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 single controls, single is untouched, the two names are gone from every published surface that carried them and the changeset now names all of them, the doc-authoring workaround kept the string honest without growing a baseline, and nothing widened. It fails on one thing, the same class as last round one reader over: on walled rigs the default-organization bootstrap still identifies the platform admin from the retired anchor and writes ownership on that basis, inside the widened surface, in a file whose own contract names this leg as its retirement point, and the tree pins that pre-L5 behaviour under isolated. Landing this head would ship a walled window in which the derivation says "not an administrator" while the bootstrap confers ownership on the same account.

Implemented-by: claude/issue-18336-retire-walled-legacy-grant-dual-read
Reviewed-by: session_019hBqDVrwbijUCoK9qsss2E

VERDICT: FAIL


Generated by Claude Code

…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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e632839ce6f53ac2d6d8faa911cf266f7add57ca

Isolated at-tier review, third round. Judged against the diff of that head over its merge base 1047fe101 (16 files, 10 commits including two origin/main merges), the card (#18336 body, the option-B ruling 5695555178, the unlock 5731154376, the REWORK handover 5737313226, both dev reports), both previous review records (5737292414, 5737610351), and the governing text re-read on origin/main at 6819dcbd6 — ADR-0131 D5, D14's named exception and the 2026-09-17 amendment; #11663 design comment 5394453215 (P3, P5, P6, Choice 4/5/6, §5 steps 3–7, rows L3/L5/L6); #16682 comment 5587754690 (Choice 4A). The PR body's two patch-round sections were read; no figure below is taken from them — every number here was re-measured. CI on this head: 34 check names, the latest run of every name is success or skipped; all seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) are success.

① Derived judgments

1. Rounds 0 and 1 are intact across round 2 and the base merge. git diff --stat 61a951c97 e632839ce6 over packages/core/src/security, packages/plugins/plugin-security/src and the four last-admin-guard* files is empty (control on the same instrument over the whole tree: 49 files, 3114 insertions — the origin/main merge). The merge 51100dbbd brought exactly three files into plugin-auth/src (objectql-adapter.ts +13 for #18728's metadata decode, organization-metadata-decode.ts and its test); none touches the anchor. No commit on main after the base touches core/src/security, plugin-security/src, plugin-auth/src, organizations/src or plugin-approvals/src (empty git log). Against round 0's head 4da89b8ea, the only change under core/src/security + plugin-security/src is the round-1 prose edit in admin-standing-surface.ts (12+/6−). So the previously passed items hold unchanged: the §6b line is narrowed by and not legacyGrantAnchorRetired (resolve-authz-context.ts:955–961), the else if arm is gone (:1026), the un-skipped fail-closed boot line and the guard's grade-1 keying (last-admin-guard.ts:941–947) are byte-identical.

2. single is still genuinely untouched — re-derived at every site, not inherited. postureEnforcesWall(p) is p !== 'single' (spec/src/security/tenancy-posture.ts:53); resolveTenancyPosture() answers single when OS_TENANCY_POSTURE is unset and OS_MULTI_ORG_ENABLED is not truthy (types/src/env.ts:147). The two new sites (ensure-default-organization.ts:332 step 2 and :240 the trigger arm) spell the identical expression through the identical two helpers, imported from the same modules core imports them from. Under single both evaluate false and step 2 and the trigger arm are behaviour-identical to base; the suite pins single and the both-inputs-unset default separately, on the same fixture as the walled arms. Zero-config rigs keep their promotion, their row and their Default Organization.

3. Both names are gone from every published surface — re-confirmed on a fresh build of this head. I extracted the head with git archive into an isolated scratch copy (the shared checkout was never touched), ran an offline install and turbo run build over the plugin-auth + organizations closure (28/28 tasks, exit 0), and read packages/core/dist: reportLegacyPlatformAdminGrant and resetLegacyPlatformAdminGrantReport occur 0/0/0 in index.d.ts/index.js/index.cjs; positive controls on the same files matchesConfiguredPlatformAdmin 4/3/4 and setPlatformAdminConfigSink 2/2/3; negative control notAnExport_xyz 0/0/0. The diff's only removed export lines are those two; the diff adds zero export lines anywhere.

4. ⭐ The closure claim, tested with my own instruments. I did not reuse the body's organization_id: null-beside-the-set grep. Three sweeps over the whole tree at the head (packages/, scripts/, apps/, examples/; excluding *.test.*, *.spec.*, __tests__/, dist/, node_modules/, *.d.ts), each classified file by file:

  • Table-shaped — every non-test source file naming sys_user_permission_set (48 files). The files that READ ROWS from that table: resolve-authz-context.ts (the resolver, keyed); last-admin-guard.ts (grade 1 keyed at :941–947; its two zero-population evidence scans at :1150 and :1179 read unscoped rows only as evidence for REFUSING a write — fail-safe direction, message-only walled clause, passed in round 1); bootstrap-platform-admin.ts (keyed round 0); ensure-default-organization.ts (keyed this round); explain-engine.ts collectGrantProvenance (:469, provenance only — standing at :562 is grants.posture === 'PLATFORM_ADMIN'); auto-org-admin-grant.ts (:935–954, org-admin set ids only, per-(user, org) reconcile); cleanup-package-permissions.ts (deletes rows of a package's sets); delegated-admin-gate.ts (write-containment gate, zero posture/platform words in the file); auth-manager.ts:4943 (self-registration set existence check before an insert); verify/src/rls.ts:475 (INSERTS a probe-persona grant on a probe set, not admin_full_access); sharing-service.ts:408 (a bypass list). Everything else is declarations, migrations, constants, prose, translations, page metadata, lint, a fixtures module, the dogfood matrix and the single-resolver gate itself.
  • Name-shaped — every non-test source file naming ADMIN_FULL_ACCESS / admin_full_access (68 files). The runtime actors on the name: the four row readers above; default-permission-sets.ts (declaration); security-plugin.ts (seeding; the PLATFORM_ADMIN_ONLY_CAPABILITIES probe, judged in item 5); audience-posture.ts:231 (refuses selfRegistrationPermissionSet naming it); posture-ladder.ts (derivePosture from evidence the resolver supplies); approval-service.ts:1402–1403, sharing-rule-service.ts:349–351, sharing-plugin.ts:1186–1188 and service-datasource/admin-routes.ts (name/capability readers of the RESOLVED envelope, judged in item 5); the rest is prose.
  • Standing-shaped — every non-test source site yielding 'PLATFORM_ADMIN', platform_admin, isPlatformAdmin* or hasPlatformAdmin* (38 files). Every runtime derivation traces to resolveUserAuthzGrants §6d or a projection of it: hasPlatformAdminStanding (resolve-authz-context.ts:1127–1139, the rung), customSession (auth-manager.ts:3847–3851), isPlatformAdminUserId (:6838–6842), the session gate isPlatformAdminUser (platform-admin-gate.ts:80–85 — the derived alias plus the pre-D2 role === 'admin' scalar, a different anchor kept readable by a 2026-08-18 ruling, not this card's), the impersonation oracle, explain (:130, :562), the automation/activation/perf-timing posture consumers, the Layer 0 decision (security-plugin.ts:6033–6034, carried rung authoritative), and platform-admin-service.ts (config + sys_user rows only; no grant read at all).

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 (SYS_USER_PERMISSION_SET, USER_PERMISSION_SET) resolve to exactly one alias, last-admin-guard.ts:334, whose every site is listed above.

What the instruments cannot see: a table name built from a template literal; packages/console/dist (the objectui bundle, excluded as dist/) and the objectui sibling repo, whose platform-admin reads come off the session payload (a projection) but were not measured here; objectstack-ai/cloud (L8, designed blind, not in this tree); node_modules (better-auth's own /admin/* routes gate on the legacy role scalar — auth-manager.ts:3374–3376 — a different, pre-existing anchor); SQL text inside drivers; seed formats other than the .ts/.json/.yaml the name sweep covered.

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: approval-service.ts:1402–1403 (perms.includes(ADMIN_FULL_ACCESS)), sharing-rule-service.ts:349–351 (systemPermissions carrying manage_platform_settings), sharing-plugin.ts:1188 (same capability, for sys_record_share listing), and security-plugin.ts:6044–6046 (the capability probe, the Layer 0 fallback for contexts that carry no rung). §6b pushes ps.name and the set's capabilities on every posture for every active held set whose grant row passes the org filter (resolve-authz-context.ts:827–829, :948, :962–968; an unscoped row passes), and the diff keeps that exactly so — the legacyGrantAnchorRetired gate sits on the standing line only (:955–961). By construction this diff changes none of those four answers. Under the governing design they are not the anchor: Choice 6A's rationale requires the envelope to stay identical so grants.systemPermissions consumers do not move silently, §6d states 「Enforcement behavior is unchanged … posture is the carried, explainable tier」, and §5 step 7 / row L6 (#11978, open, pm:blocked) is the leg that removes the capability content from walled holders by census and reap. That is the 「anchor, not the grant」 boundary this PR pins and both earlier rounds passed. Not a FAIL; the consequence is stated in ③ because the PR body names one member of the family and not the family.

6. The newly keyed reader. Same predicate as the derivation site (item 2), hoisted once per call (:332), read from the environment so it adds no query (pinned: under a wall neither sys_permission_set nor sys_user_permission_set is read). Accept/reject change under a wall, derived from the code: with no verified declared owner the helper answers no_admin and writes nothing where it used to create the Default Organization and bind the oldest legacy holder as owner (and hand them the seeded rows through the injected claimSeedOwnership). The config anchor still binds under a wall, and it wins over a legacy holder present in the same fixture (pinned). Reachability was confirmed rather than assumed: plugin-auth's own wiring skips the helper under a wall (auth-plugin.ts:1100); @objectstack/organizations wraps it (organizations/src/ensure-default-organization.ts:40) and runs it on kernel:ready and after every write the exported predicate matches (organizations-plugin.ts:461–501, option default true).

7. The no_admin answer is a wait, not a stall — every walled path traced. (a) Walled + undeclared: plugin-auth refuses boot (auth-plugin.ts:550 throws naming OS_PLATFORM_OWNER_EMAIL), so this rig never reaches the helper. (b) Walled + declared + unverified: no_admin, no write; the verifying update through the better-auth adapter lands as dataEngine.update('sys_user', { …snake_case patch, id }) (objectql-adapter.ts:992/1009), the engine builds opCtx = { object, operation: 'update', data, … } (objectql/src/engine.ts:11576–11582), the organizations middleware runs after next() and asks the predicate, whose sys_user update arm tests hasOwnProperty(data, 'email_verified') — true — so runEnsure re-runs and step 1 finds the now-verified owner. The organizations pin's operator case drives exactly that write shape end to end through the plugin's own registered middleware and is green on this head. (c) Walled + declared + verified before boot: the kernel:ready run binds immediately. (d) A legacy holder already bound as owner before the upgrade: no row is touched; the declared owner is bound on the existing slug='default' organization (step 4 reuses it). No path ends in a permanent stall.

8. The trigger split is correct. Under a wall step 2 is skipped, so the helper's answer is a function of sys_user rows and the config alone; a sys_user_permission_set insert cannot move it, and retiring that arm is cost-only. Keeping the sys_user arms is load-bearing — on a walled rig the verifying update is the only write that ever grows the population — and both directions are pinned (ensure-default-organization.test.ts:466–480). shouldReplayBootstrapFor in plugin-security spells the same walled exclusion (bootstrap-platform-admin.ts:341). One caution, not a finding: resolveTenancyPosture() is now read on every sys_user_permission_set write's middleware pass — an environment read that throws only on a value the boot already refused.

9. Widening: none. Zero added export declarations in the diff; plugin-auth's entry unchanged; no new key on any payload (the helper's result type is unchanged); no newly accepted input; the two new imports into plugin-auth resolve to declared dependencies.

10. Dissolution, re-run on my own build rather than quoted. Baseline: ensure-default-organization.test.ts 41/41, walled-default-org-self-registrant.pin.test.ts 5/5. Head blob of ensure-default-organization.ts a17950806…, disk identical before and after. Mutation 1 (drop the step-2 gate, mutated blob c717c8344…): plugin-auth suite 3 failed / 38 passed — the walled binding arm (group: expected { defaultOrgCreated: true, … } to match object { defaultOrgCreated: false, … }), the unverified-owner wait arm (expected undefined to be 'no_admin'), the no-added-read pin; every single control green. The same mutation against the organizations pin: 1 failed / 4 passed — the inverted case only (expected [ Array(1) ] to have a length of +0 but got 1); the operator positive control stayed green. Mutation 2 (delete the trigger's walled exclusion, blob 56bd6f785…): 1 failed / 40 passed — the trigger arm only. Restore proven by blob equality with the head, not by an exit code. Per the security family's disclosure discipline these describe the consequence of a proposed mutation to this branch and carry no reproduction recipe for the shipped product.

② Semver level

Level: correct. Convention read from scripts/check-changeset-no-major.mjs on origin/main: during the launch window a breaking change ships minor, never major, and a Clause-② PR grades at least one package whose published source it moves minor or above. The changeset grades six packages minor, opens with the BREAKING banner, and carries exactly one ADR-0087 marker, not-required (no-migration-prescription), whose category is still right after this round (no Zod schema, no packages/spec declaration, no stored representation; the default-organization keying is a behaviour keyed on an environment variable, and no row is written, deleted or re-owned). All six sit in the one 70-member fixed group (.changeset/config.json: one group; @objectstack/organizations and @objectstack/plugin-auth both in it), so the bump is identical whatever is named.

Naming: correct, and @objectstack/organizations is correctly named, not over-named. The rule the author states — named because the behaviour an operator gets from that package moves, never because a test was edited — is the right one and it applies: the package is non-private with a . entry (package.json), it is the only runtime that calls ensureDefaultOrganization on a walled rig (plugin-auth skips it there; a non-test grep for callers finds only the two wirings), and the helper it wraps and republishes (organizations/src/index.ts:74) now answers differently for a legacy holder there. The tombstone belongs in that CHANGELOG; this is the same reasoning as @objectstack/runtime and @objectstack/plugin-hono-server in round 1. Nothing is missing: no package wholesale re-exports @objectstack/plugin-auth or @objectstack/organizations (a quote-agnostic grep finds none), no other package calls the helper, @objectstack/plugin-auth already covers the trigger predicate's changed answer, and @objectstack/plugin-security covers the boot line and the Layer 0 decision site. One nit, prose-only: the ADR-0087 marker's sentence names the guard and the two re-export packages but not the default-organization keying; the category is unaffected.

③ Boundary flags

  • The organizations pin inverted rather than deleted — the suite still has a working positive control and the inverted assertion can fail. The operator case (walled-default-org-self-registrant.pin.test.ts:259–287) is the control: same middleware, one fact flipped (the declared address reads verified), asserting the org is created and the operator is bound as owner — green on this head and green under my mutation 1, which is what makes the inverted case (:289–332, cloud#1509's fixture: an unscoped admin_full_access row for the self-registrant under isolated, owner declared but absent) a measurement: under mutation 1 it alone reds (item ①10), so it is falsifiable rather than vacuous. The suite resolves @objectstack/plugin-auth, @objectstack/core and @objectstack/types from source (organizations/vitest.config.ts anchored aliases), so it measures this head's helper and not a stale dist. The out-of-lane touch is exactly one test file; organizations/src runtime is untouched (the diff's only path in that package is the pin). Declared, forced, test-only — not creep.
  • plugin-approvals/src/approval-service.ts:1402 left unchanged — the argument is verified, not accepted. isOverrideActor reads context.posture === 'PLATFORM_ADMIN' || perms.includes(ADMIN_FULL_ACCESS). context.permissions is grants.permissions (resolve-authz-context.ts:557), and §6b pushes the held set's name on every posture (:948) for every active grant row that passes the org filter (:827–829), an unscoped row included — the gate this diff adds touches only the standing line (:955–961). So for a walled account holding the legacy row, before this diff both arms were true and after it the second still is: the function's answer is unchanged, exactly as the author says. It is, however, one member of the family in ①5 — sharing-rule-service.ts:349–351 and sharing-plugin.ts:1188 (capability), security-plugin.ts:6044–6046 (the probe fallback) read the same envelope content the same way. Consequence, stated abstractly: on a walled rig that still carries a legacy row, an account the derivation now grades MEMBER and the boot line reports as no administrator can still act as an approvals override actor, delete a platform-global sharing rule, and cross the wall on a hand-built context, by virtue of the held set's name or capabilities — as it could before this diff. Not a widening, not opened here, correctly not fixed here; its carrier is L6's census and reap (platform-admin re-anchor L6 (reap): reader census on a walled rig; organization-scope auto-org-admin-grant's resolver; stop minting org-less rows; only then reap #11978, open), which is the leg that takes the capability content off walled holders. The body should have named the family rather than one member.
  • Not flagged by the author and should have been: with this diff, on a walled rig with a legacy row the carried rung and the capability probe disagree for that holder on every tenant-object operation that passes the resolver, so security-plugin.ts:6035–6043 emits its ADR-0099 I4 breadcrumb (Layer 0 exemption: carried posture rung and capability probe disagree; enforcing the (narrower) carried rung) per operation. The narrower rung is enforced — correct direction, no security consequence — but a log line classified as a defect now fires on a state this leg creates on purpose, until the reap removes the content. Log-only; carrier platform-admin re-anchor L6 (reap): reader census on a walled rig; organization-scope auto-org-admin-grant's resolver; stop minting org-less rows; only then reap #11978. Non-verdict-driving.
  • The single pin for the whole test file — forced, test-only. The helper's answer became posture-dependent in this diff and the file read the ambient environment without setting it; the same correction round 1 made to the two guard suites.
  • ADR-0131 and packages/spec untouched — correct. The file list carries no packages/spec/, docs/adr/, .claude/, skills/, AGENTS.md or CLAUDE.md path; Governed Surface Queue Guard is success. The amended D5/D14 text on origin/main says what the diff does: walled half and both symbols on 17.x, no row written, deleted or re-owned (C3, v18). The card's corrected goal sentence (「no grant row with a NULL organization remains」) reads wider than the amendment; the amendment, platform-admin re-anchor L5 (migration): time-boxed legacy-grant dual read, then its removal one minor later #11975 and platform-admin re-anchor L6 (reap): reader census on a walled rig; organization-scope auto-org-admin-grant's resolver; stop minting org-less rows; only then reap #11978 carry that difference, not this PR.
  • Finding 3's carrier exists: [finding] the platform-admin derivation becomes posture-dependent when #18336 lands, and two shipped documents still say it is not — authorization.mdx's "either of which is sufficient" and three access-security checklist lines naming the retired pointer #19145 is open (finding, Blocked-by: #18336).
  • Claims in the body I did not verify: the full-package suite counts (CI Test Core on this head is the evidence I used); the round-0/1 dissolution runs (not re-run; the code they mutate is byte-identical since 61a951c97).

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, single is untouched at every site, the two names are absent from a fresh build with controls, nothing widened, the level and the six-package naming are right, and both boundary flags hold under verification. The one open residue is by design and carried by an open leg.

Implemented-by: claude/issue-18336-retire-walled-legacy-grant-dual-read
Reviewed-by: session_019hBqDVrwbijUCoK9qsss2E

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Clause-② provenance — carriers cleared against this head

domain:services seat (#6021) · session_019hBqDVrwbijUCoK9qsss2E · written 2026-09-19T00:54Z.

review of record issuecomment-5738009254 — ## Contract review, VERDICT: PASS
head judged e632839ce6f53ac2d6d8faa911cf266f7add57ca — the current head, ⛔ not a predecessor
tier ⭐ confirmed from the reviewer's transcript, ⛔ not self-report and ⛔ not get_session: 148/148 harness stamps at CONTRACT_REVIEW_TIER, zero fallback evidence
machine check check-clause2-carriers --pair 19136 → exit 0, 「a review of record names this head」
carriers needs:contract-review removed from both the card (#18336) and this PR, in this act

⚠️ Independence pair: Implemented-by: claude/issue-18336-… · Reviewed-by: session_019hBqDVrwbijUCoK9qsss2E. Both the dev and all three reviewers ran as subagents of this seat's session ⇒ this reads SELF-REVIEW. Under mode:subagent that is by design; stated, ⛔ not glossed. The isolation available was applied — each reviewer got the card, the rulings and the PR, ⛔ never this seat's dispatch order or conclusions.

Three at-tier reviews, three different heads, ⛔ never a re-run for a PASS

head verdict what it found
4da89b8ea FAIL the retirement was done at one of the anchor's readers — last-admin-guard.ts still counted the legacy holder
61a951c97 FAIL a different reader — ensure-default-organization.ts, which confers rather than counts
e632839ce PASS the reader set closed

⭐ 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 decisive

Two 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, console/dist and the objectui sibling, the cloud repo, node_modules, driver SQL text, non-.ts/.json/.yaml seed formats).

⇒ zero unkeyed readers of the unscoped-row anchor. ⭐ And one reading in that sweep is worth keeping on the record: explain-engine.ts has 0 posture reads and looks identical to the defect, but it consumes the resolver's verdict instead of re-deriving — so the same measurement that condemns one file exonerates another, and only reading the code tells them apart.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 19, 2026 00:54
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 74832b6 Sep 19, 2026
46 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-18336-retire-walled-legacy-grant-dual-read branch September 19, 2026 01:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants