Skip to content

fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it - #24373

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-11979-default-org-admin-grant
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-11979-default-org-admin-grant

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11979
Part of #15212
Clause-②: yes (narrowing)

C8 stage 8b-0a (plan 6251466182 on #15212). Under the single tenancy posture the first-user platform-admin promotion keeps its zero-config behaviour, and the admin_full_access grant row it writes is now owned by the Default Organization (ADR-0131 D5, maintainer ruling A on #18707). Every reader of platform standing reads that row beside the organization-less one. The walled posture is unchanged.

What changes

  1. The promotion writes the Default Organization (packages/plugins/plugin-security/src/bootstrap-platform-admin.ts). The grant carries organization_id = the Default Organization: slug = 'default', else the only organization. That is plugin-auth's boot-invariant definition, resolved through the module's existing resolveDefaultOrganizationId. A deployment that names no Default Organization (several organizations, none default) is not promoted. It logs one error line and returns reason: 'default_organization_unresolved', and writes no grant without an owner. The holder scan (leg A, and the predicate legs B and C apply) reads the Default Organization's grant and the organization-less one. A user holding both is one holder, and whichever row sorts first by id answers.

  2. The resolver's anchor is one predicate with the posture as its parameter (packages/core/src/security/resolve-authz-context.ts). grantAnchorsPlatformStanding(grant, { walled, defaultOrganizationId }): under a wall nothing anchors (unchanged); under single the Default Organization's grant and the organization-less row anchor. The anchoring grant applies in whichever organization the principal acts in, as the organization-less row always did. Any other organization's grant of the set confers the set, never standing. sys_organization is read only for a principal holding an organization-owned admin_full_access grant under single, so the batch-equivalence goldens do not move.

  3. plugin-auth reads the same row (ensure-default-organization.ts). The grant anchor reads the Default Organization's grant. Under single, step 4 reuses the Default Organization by the invariant's definition (a sole organization with another slug included) instead of minting a second, default-slugged one that would take the anchor off the promoted grant. Under a wall only the default-slugged one is reused, as before.

  4. The extra sys_organization reads, and when they run. The resolver reads it only under single, and only for a principal holding an organization-owned admin_full_access grant: one small indexed read when a default-slugged organization exists, two otherwise, per uncached request (deduplicated within a request; the cross-request grants cache is off by default). Every other principal reads nothing more, which is why the batch-equivalence goldens (whose fixture holds the organization-less row) do not move. The last-administrator guard adds one or two reads per enumeration whenever an admin_full_access grant names an owner, so about three to six small reads per guarded write on sys_user, sys_member, sys_user_permission_set, the activation ledger and sys_organization.

Landing points outside the claim's file surface, and why

  • packages/plugins/plugin-auth/src/last-admin-guard.ts is the break-glass guard. Its enumeration, its "emptied rather than fresh" evidence reads and its switched-off-set read all keyed on !organization_id. Left alone, the promoted admin's grant would count as no administrator, and the guard would wave through the ban or revocation that ends the last one. It now reads the anchor the resolver reads. Because the anchor now depends on sys_organization, it also judges the two organization writes that move it: changing the Default Organization's slug while another organization exists, and deleting it.
  • packages/core/src/security/admin-standing-surface.ts gains sys_organization as a derives table (columns id, slug, measured by admin-standing-surface.test.ts). The f8eb736 correspondence gate turned red without it, and is green with the guard's new standing-key list.
  • No ceremony file is touched. A3 below shows the ceremony's attribution is the intended D10 fate. The fix is on the readers' side.

A3 — measured: the ceremony attributes the promoted admin's grant to the Default Organization

On 18.0.0-next.3 semantics (this branch's base e4bcced0), with the cli acceptance fixture of organization-ownership-apply.test.ts plus one promoted-admin grant (grant_admin, organization_id NULL, permission_set admin_full_access). This was a scratch copy of the fixture run under --project integration and deleted afterwards:

A3-READING [["attribution","done"], …] [{"id":"grant_admin","user_id":"usr_admin","permission_set":"admin_full_access","organization_id":"org_default"}]

So after os migrate organization-ownership --apply the grant is the Default Organization's. On the base tree that row conferred nothing. The resolver read !ownGrant only: the new [ADR-0131 D5] resolver tests fail on base (2 of 6). And the holder scan found no NULL holder and promoted again: the new fresh-boot golden "an upgraded database whose administrator's grant the ceremony gave the Default Organization promotes nobody else" fails on base and passes here. That is the live defect the dispatch suspected. The rewrite step blocked in that scratch run only because the fixture's catalog double serves no admin_full_access, which has nothing to do with attribution.

Tests

On the final head bc90a53af6 (after merging origin/main):

  • pnpm --filter @objectstack/core test: 104 files, 2558 passed.
  • pnpm --filter @objectstack/plugin-security test: 188 files, 3739 passed, 45 skipped.
  • pnpm --filter @objectstack/plugin-auth test: 162 files, 3140 passed, 10 skipped.
  • typecheck for core, plugin-security and plugin-auth: exit 0 (plugin-auth after its build, which its examples project reads).
  • Platform-admin dogfood (packages/qa/dogfood, on a full build): admin-platform-admin-standing, org-admin-affordance-reach and admin-credential-lifecycle pass, 26 tests. The boot log shows first user promoted to platform admin: admin@objectos.ai three times and no withheld promotion.

New or flipped pins assert the substance:

  • the fresh-boot golden's grant owner null becomes org_default;
  • an attributed grant is a holder, and nothing is re-promoted;
  • a database naming no Default Organization withholds at error and writes nothing;
  • the resolver confers standing from the Default Organization's grant, in it and with no active organization, and from a sole organization of any slug, but not from another organization's grant, not with several organizations and none default, and never under a wall, which reads no organization;
  • the guard counts the holder and refuses the ban, the revocation, the re-scoping, the re-slugging and the delete; the controls are another organization's grant and a sole organization's slug or name change;
  • ensureDefaultOrganization binds the Default Organization's grant holder.

The test fixtures that promote now hold the Default Organization the single boot creates.

Reverse verification. The fix was committed first. The four source files were then restored to base e4bcced0 and the new tests run, and the files restored to HEAD (proved by an empty git diff HEAD and HEAD-blob hash equality). Results on base: core D5 suite 2 failed; plugin-security fresh-boot golden 3 failed; plugin-auth guard and default-org suites 7 failed. All green on HEAD.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 73 families at 11016ab9. All 73 ran with exit 0 after a full build (check:i18n and check:dual-build-cjs-loads answered PREREQUISITE NOT MET before it, and green after). --ran reconciliation: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero)". The derivation is unchanged on the final head. A subset (check:published-files, check:cross-package-test-inputs, check:durability-log-level, check:startup-registry-verdict, check:issue-citations, check:doc-authoring) was re-run after the last source commit, all exit 0. Every family CI runs is read from this PR's check-runs.

Acceptance notes

  • Clause-②: yes (narrowing). Breaking, shipped as minor on all three packages under the launch-window convention; the changeset carries the three FROM → TO pairs with their fixes and the ADR-0087 disposition not-required (no-migration-prescription). Narrowings: (1) plugin-security — a single deployment with several organizations and none slugged default is no longer promoted (one error line, reason: 'default_organization_unresolved', no row); fix: slug the intended organization default. (2) plugin-auth — a sys_organization slug change or delete that would leave no administrator who can sign in is refused (403 PERMISSION_DENIED); fix: give another account administrator standing first. (3) plugin-auth — ensureDefaultOrganization under single reuses a sole organization of any slug instead of creating a second default-slugged one. Widening: under single a Default-Organization-owned admin_full_access grant now confers PLATFORM_ADMIN and applies in every organization (a pre-existing Default-Organization-scoped grant gains the rung on upgrade), and sys_organization becomes a standing-deriving table. No spec, REST answer or package entry changes: the new core helpers and the guard's organization key list are module-private; the bootstrap's return gains one reason string.
  • Line budget: the dispatch estimated about 200 source lines. This lands about 620 source lines and 425 test lines. The extra source is the break-glass guard (enumeration, evidence reads, organization hooks), which the anchor change made necessary, plus its explanatory comments.
  • Not in this PR: the organization-less reader's retirement (stage 8c-2) and engine-wide system-insert stamping (stage 8b). The organization-less row still anchors under single, by D5's "until D10's migration clears it".
  • platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback #11979's Constraints paragraph ("config now required for admin") is superseded by ruling A, as the card's discharged note says. Nothing here requires config under single.

Generated by Claude Code

…ault Organization, and the resolver and holder scans read it

Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf
Co-authored-by: Claude <noreply@anthropic.com>
…e Default Organization's grant

Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf
Co-authored-by: Claude <noreply@anthropic.com>
…ollow the Default Organization's grant

Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf
Co-authored-by: Claude <noreply@anthropic.com>
…e Default Organization, and every reader of platform standing reads it

Under the single tenancy posture the first-user promotion writes its
admin_full_access grant owned by the Default Organization (ADR-0131 D5),
never NULL. The authorization resolver, the holder scan, the
default-organization bootstrap and the last-administrator guard read that
row beside the organization-less one, so a database the ownership
ceremony migrated keeps its administrator and is not promoted again.

Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf
Co-authored-by: Claude <noreply@anthropic.com>
…te; the guard reads the map's entry

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

github-actions Bot commented Oct 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 25 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6868514b1c73c5fb2f7e1112a4749f182825447d.

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

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

Coarse fallback — 42 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 6868514b1c73c5fb2f7e1112a4749f182825447d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6868514b1c73c5fb2f7e1112a4749f182825447d

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bc90a53af6d439985c6d9185e7d52dd9d399aab6
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admin-standing-surface.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/ensure-default-organization.ts, .changeset/11979-default-org-admin-grant.md, packages/core/src/security/admin-standing-surface.test.ts, packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts, packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts, packages/plugins/plugin-auth/src/ensure-default-organization.test.ts, packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts, packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts, packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts, packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts, packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts, packages/plugins/plugin-security/src/builtin-positions.boot.test.ts, packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts, packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts + packages/plugins/plugin-auth/src/default-organization-invariant.ts, packages/plugins/plugin-auth/src/tenancy-service.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-security/src/suggested-audience-bindings.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/grant-permission-set-name.ts, packages/platform-objects/src/identity/sys-organization.object.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/utils/organization-ownership-plan.ts, packages/cli/src/utils/organization-ownership-apply.ts, docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D3, D5, D9, D10), AGENTS.md:1087-1100, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs

Head reviewed is the one the brief named; it had not moved (gh api …/pulls/24373 → head.sha = bc90a53a…, 23 files, +909/−156, open against main). Rendered by an isolated at-tier reviewer subagent of the epic seat, from the card body and all nine comments, ADR-0131 D5/D10 on origin/main, plan 6251466182 rows 8b-0a and 8c-2, the PR body, the net diff origin/main...bc90a53a, and the head's 40 check-runs. The only commands run were gh api reads, git fetch/git show/git diff on the fetched refs, and the brief-prescribed node scripts/pm/record-recognisers.mjs --template (and its --paths/--face form from the origin/main copy, to read the face and Scope: floor). Nothing was built, tested or re-gated.

Check-runs on the head: 40, all completed; 34 success, 6 skipped (the path-filtered Dogfood matrix shard, Build Docs, Temporal Conformance, Dogfood Verify CLI, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 pending. Check Changeset, Lint & Repo Gates (which carries check:adr-0087-registration), Governed Surface Queue Guard, both single-writer guards and The card this PR closes must claim this branch are green. They are green against the declaration as written (Clause-②: no, three patch), which is self-consistent and asks the ADR-0087 gate nothing; see ② for why that declaration is wrong.

Face. reviewFace on the 23 paths returns no schema and no rules hit (no packages/spec/src/**, no governed path), so hits is empty and the changeset's Clause-②: line decides between the narrowing and the declaration face. As committed (no, no arm) it reads the declaration face; as this record judges it must read ((narrowing) arm), the narrowing face, whose Scope: floor is every path of the diff outside the prose faces. Plan 6251466182 assigns 8b-0a to CONTRACT_REVIEW_TIER by name ("platform standing"). Scope: above therefore lists every code path of the diff, the changeset, and the files the hunks make claims about.

① Derived judgments

(a) grantAnchorsPlatformStanding — right on D5, one derivation site, four spellings of the predicate; the widening it performs is bounded but real.

  • packages/core/src/security/resolve-authz-context.ts:912-920: walled → false; else owner === null || (defaultOrganizationId !== undefined && owner === defaultOrganizationId). Read at exactly one place (:1497-1510, the §6 grant leg): the posture from postureEnforcesWall(resolveTenancyPosture()) as before, the Default Organization from readDefaultOrganizationId (:934-942). Under a wall nothing anchors and sys_organization is never read (pinned, resolve-authz-context.platform-admin-config.test.ts: "a WALLED rig … does not ask which one it is"). This is D5's text: the single promotion row "owned by the Default Organization", the walled posture "no grant row … (unchanged)" plus the [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 L5 retirement, and the organization-less row "until … D10's migration … clears it". Right.
  • The anchoring grant applies in every organization (:1508-1510, grantAppliesInTenant(...) || anchorsPlatformStanding(r)), as the organization-less row always did (grantAppliesInTenant(null, …) is true). A deactivated admin_full_access still confers nothing: take(body, active) and the && active at :1602. Any other organization's grant confers the set, never the rung (pinned). Right.
  • One code path with a posture parameter (the card's Constraint): the DERIVATION of standing is one site, resolve-authz-context.ts §6, with { walled, defaultOrganizationId } as its parameter. Met. But the PREDICATE now has four spellings: core :912-920, bootstrap-platform-admin.ts:585-587 (isAnchorHumanHolder), last-admin-guard.ts:1017-1031 (anchorOwnerTest), ensure-default-organization.ts:505-512 (the [null, defaultOrgId] loop). That is the same count as before the PR (four !organization_id spellings), kept module-private on purpose so no package surface changes. Nothing but tests pins the four to each other. Not a defect of this PR; it is a lockstep-edit risk for 8c-2, when the null arm retires in all four at once — flag (③).
  • Can a caller-reachable write produce a Default-Organization admin_full_access grant for someone who should not hold platform standing? Writers of sys_user_permission_set: (i) system writers — this promotion (:1108-1116) and the D10 ceremony (organization-ownership-inventory.ts:567 fate attribution, organization-ownership-plan.ts:1193-1209 via default-organization) — both by design; (ii) a platform admin through Setup CRUD (admin_full_access '*' allows create) — already PLATFORM_ADMIN; (iii) organization_admin and its wall-less organization_admin_no_bypass derivative: sys_user_permission_set: { allowCreate: false, allowEdit: false, allowDelete: false } (default-permission-sets.ts:475; deriveWallLessOrgAdmin copies everything but the superuser bits) — denied; (iv) the DelegatedAdminGate (delegated-admin-gate.ts:799-828): a delegated scope with manageAssignments whose assignablePermissionSets lists admin_full_access writes a grant row under the caller's context, and under single that row lands owned by the caller's organization — the Default Organization. A scope's assignable list is bounded by its outer scope (:876-905) and the root scope is a platform admin's authoring, so the authority chain is a platform admin's own decision to let someone hand out admin_full_access. On base such a row conferred the set ('*', modifyAllRecords) without the rung; on this head it confers PLATFORM_ADMIN. The dev's "writing needs modifyAllRecords" is therefore not the whole answer — the delegated path exists — but it is bounded to platform-admin-authored authority and, under D3 ("under single the organization is the environment"), handing out admin_full_access in the Default Organization already was handing out the environment's superuser. Judgment: accept-set widening on the permission boundary, bounded, acceptable in substance — and it must be declared as a widening (②). The same holds for any pre-existing admin_full_access grant a 17.x platform admin wrote scoped to the Default Organization: it gains the rung on upgrade.

(b) The Default Organization definition — seven copies, all agree, no reachable topology makes them disagree.

Read: core readDefaultOrganizationId (:934-942), the guard's readDefaultOrganizationId (last-admin-guard.ts:973-1006), plugin-security resolveDefaultOrganizationId (suggested-audience-bindings.ts:549-558, what the promotion resolves through, :713-720/:807), plugin-auth resolveDefaultOrgId (tenancy-service.ts:225-231), ensureDefaultOrganizationExists (default-organization-invariant.ts:87-93), findDefaultOrganizationId (ensure-default-organization.ts:341-346), and the ceremony plan (organization-ownership-plan.ts:446-451, :493). Every one: slug = 'default' (exact; the slug carries a global unique index, sys-organization.object.ts:465, so at most one row), else the row when exactly one exists (limit 2, length === 1), else none. sys_organization has no soft-delete or status column, so "the only organization" cannot diverge on a hidden row. On every topology (0 / 1 / several-with-default / several-without) they answer alike. They differ only in what a FAILED read does: core tryFind throws AuthzStoreUnavailableError on an unreachable store and answers [] on an unprovisioned table; plugin-security's throws and readPromotionGrantOwner catches to undefined → promotion withheld with the (or the read was refused) wording; tenancy-service folds to null; the invariant propagates. On a reachable, provisioned store they agree. The guard's copy reads the Default Organization as the pending write would leave it (by design: a slug change or delete is judged on the after-image). Right. Consolidation is deferred on layering (core cannot import plugins) — flag for 8c-2 (③).

(c) The A3 fix — right; pinned against the ceremony's output SHAPE, not against the ceremony.

The ceremony attributes a single database's organization-less sys_user_permission_set rows to the Default Organization with no exemption for the promoted admin's grant (organization-ownership-inventory.ts:567, anchors []; organization-ownership-plan.ts:1193-1209). On base the resolver read !ownGrant only and the holder scan asked organization_id: null only, so after --apply the admin lost PLATFORM_ADMIN and the next boot promoted again. On this head the resolver anchors the attributed row (the [ADR-0131 D5] suite), and the holder scan's leg A asks both owners (bootstrap-platform-admin.ts:607-618), so "an upgraded database … promotes nobody else" (fresh-boot golden) — a user holding both rows is one holder, the lower id answering (:616). The fix is right. The pin, however, inserts organization_id: 'org_default' by hand; the cli acceptance fixture (organization-ownership-apply.test.ts) is not extended with an admin grant, and the dev's A3 reading came from a deleted scratch copy. The equivalence rests on (b) — the ceremony plan and the readers sharing one definition — which this record verified by reading, not by a pinned run. Flag, non-blocking (③).

(d) Cost of readDefaultOrganizationId on the authorization path.

Resolver: runs once per resolveUserAuthzGrants, after Leg 1, only when !walled AND the principal holds an active admin_full_access grant with a non-null owner (:1498-1502). One indexed read when a default-slugged organization exists (the normal post-PR single deployment), two otherwise. Deduplicated within a request by withRequestGrantsMemo; the cross-request grants cache is off by default (OS_AUTHZ_GRANTS_CACHE_TTL_MS=0). So the promoted administrator pays one extra small read per uncached request under single; every other principal pays nothing; the batch-equivalence goldens are unchanged because their fixture holds the organization-less row (the PR's sentence is true of that fixture, not of a post-D5 admin). Guard: anchorOwnerTest adds one or two sys_organization reads per enumeration whenever any admin_full_access grant names an owner (always, post-PR, under single), and enforceStanding enumerates two or three times per guarded write plus the emptied-vs-fresh and dangling reads (:1098, :1295, :1327) — about three to six extra small reads per write on sys_user/sys_member/sys_user_permission_set/the activation ledger/sys_organization. Writes are rare and already pay comparable reads. Acceptable; un-stated in the PR and changeset, and should be.

Other accept-set and permission changes, each named:

  • Promotion withheld when no Default Organization is named (bootstrap-platform-admin.ts:1091-1107: error line, reason: 'default_organization_unresolved', no insert). Right under D5 (no NULL row) and D10 fate 4 (never a guessed owner). It IS a narrowing from base, and the topology is live: auth-plugin.ts:850-855 only warns on ambiguous and the boot proceeds; the tenancy census reports it at error with the two remedies. Right in behaviour; wrong in declaration (②).
  • Holder scan leg A asks two owners, ordered by id, lowest wins (:607-618); legs B and C apply the same predicate. Deterministic. Right.
  • bootstrapPlatformAdmin's return: reason?: string (:732) — a new value is not a type-surface change; the PR body's claim holds. Right.
  • Last-admin guard (last-admin-guard.ts): the enumeration (:1098-1121), the emptied-vs-fresh evidence (:1295-1296) and the switched-off-set read (:1327-1340) now count the Default Organization's grant; beforeUpdate on slug and beforeDelete on sys_organization at priority 20 (:1768-1781, :1833-1842), judged on the after-image, with id excluded as a non-standing key. Controls pinned (another organization's grant; a sole organization's slug or name change). Right. One write that moves the anchor is NOT hooked: an INSERT of a second organization while the sole organization is not slugged default (see ③).
  • admin-standing-surface.ts:154-167: sys_organization as derives, columns id, slug; the f8eb736 correspondence gate is green on it. Right.
  • ensureDefaultOrganization step 4 (:557-560): under single, reuse by the invariant's definition (a sole organization of any slug); under a wall only slug='default', as before. Right — tenancy-service.resolveDefaultOrgId already bound users by that definition, and a second default-slugged organization would have BECOME the Default Organization and moved the anchor off the promoted grant. The golden move (grant-readers-by-name.golden.test.ts SINGLE owners ['usr_admin'] → ['usr_admin','usr_orgadmin']) is recorded in the file. Right in behaviour; a narrowing to declare (②).
  • Walled posture: defaultOrganizationId is undefined under a wall at every site (:807; core :1498; guard :1025; plugin-auth :468-469), so the walled branch reads, writes and refuses exactly as on base. Unchanged, as the PR says.
  • Cloud's legacy row: cloud confers tenant-owner standing by writing the organization-less admin_full_access row (card comment 5521222826). That row still anchors under single here, so this PR does not lock cloud environment owners out; its retirement is 8c-2's and stays behind cloud's anchor move (platform-admin re-anchor L8 (cloud seam): control-plane env injection + invite-only interaction — cloud seat leg, designed blind #11977). Right.

② Semver level

The declaration is wrong, on the PR and on the changeset, and so are the bumps. Three narrowings and one widening ship under Clause-②: no with "@objectstack/core": patch, "@objectstack/plugin-security": patch, "@objectstack/plugin-auth": patch (.changeset/11979-default-org-admin-grant.md:2-4, :9; PR body line 3 Clause-②: no and the "Acceptance notes" bullet "Clause-②: no. No spec, REST answer or package entry changes…"). AGENTS.md:1088: "(narrowing) is BREAKING"; :1089-1090: a breaking changeset "must state the FROM → TO mapping and the one-line fix"; :1099-1100: "A breaking changeset must also state its ADR-0087 disposition, in writing"; check-changeset-no-major.mjs:74-79: in the launch window a breaking change ships as minor; check-adr-0087-registration.mjs:631 reads the (narrowing) arm as the breaking signal and will then demand the marker. The gates are green today only because the arm is absent — the exact hole #16421 closed.

The narrowings the diff performs:

  1. plugin-security — FROM: a single deployment holding several organizations with none slugged default promoted its first user with an organization-less admin_full_access grant. TO: not promoted; one error line; reason: 'default_organization_unresolved'; no grant row written. Remedy: give the intended organization the slug default (or merge down to one organization, or declare a walled posture) — the next kernel:ready / sign-up pass promotes.
  2. plugin-auth — FROM: a sys_organization slug change or delete was never refused. TO: refused (PERMISSION_DENIED, 403) when the row is the Default Organization whose admin_full_access grant is the last unbanned administrator's anchor. Remedy: grant another account administrator standing first (the refusal says so).
  3. plugin-auth — FROM: ensureDefaultOrganization under single with a sole organization of another slug created a second, default-slugged organization and bound the administrator there. TO: it reuses the sole organization; no second organization is created. Remedy: none needed; an install that wants a default-slugged organization beside its existing one re-slugs the existing one or creates it explicitly.

The widening (①a): FROM: under single only an organization-less admin_full_access grant conferred PLATFORM_ADMIN. TO: a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped admin_full_access grant gains the rung on upgrade; sys_organization becomes a standing-deriving table. That is "放宽接受集" on a permission boundary — the same class plan 6251466182 graded yes for 8a's read-scope widening.

Required text:

  • .changeset/11979-default-org-admin-grant.md:9 — Clause-②: yes (narrowing) (both facts true; clause2-line.mjs reads both). If the seat reads the Default-Organization row as D5's REPLACEMENT of the organization-less row rather than a widening of the accept set, Clause-②: no (narrowing) is the floor; Clause-②: no without the arm is wrong either way.
  • :2-4 — all three bumps minor (core carries the widening; plugin-security narrowing 1; plugin-auth narrowings 2 and 3).
  • Body: a **BREAKING** (an accept-set narrowing), shipped as minor under the launch-window convention line, the three **FROM:** / **TO:** pairs above with their one-line remedies, and the widening stated as such — the shape stock changesets 24062-authored-install-scope-project.md and 24086-install-primitive-parses-manifest.md use.
  • The ADR-0087 disposition marker pnpm check:adr-0087-registration will demand once the arm is present: exactly one HTML-comment marker of the form adr-0087: not-required (no-migration-prescription) … — the honest category here: no spec key, export, option, error code or stored shape is added, removed or renamed; the only stored-shape move (NULL → Default Organization on the grant row) is D10's ceremony, already the ledger's business under C3, so objectstack migrate meta has nothing to rewrite. State the other categories closed on facts, as 24062 does (the packages publish; no ADR-0087 id is touched; nothing exported is retired).
  • PR body line 3 and the "Acceptance notes" bullet: the same arm and the same wording; the sentence "No spec, REST answer or package entry changes" stays true and is not the Clause-② question.
  • Plan 6251466182 row 8b-0a reads "Clause-②: no (the dev measures)": the measurement is yes (narrowing) (floor no (narrowing)); the seat updates the row.

③ Boundary flags

Q1 (os-dev-report) — "every Default-Organization admin_full_access grant confers platform standing under single": NOT settled by D5's text alone; settled by D3 + D10 fate 3 together. Accept A. D5 says the promotion's row is owned by the Default Organization and no NULL row is produced; it does not say the anchor is every such row. What settles it: D3 ("under single the organization is the environment"), and D10 fate 3, whose ceremony produces exactly this shape with no column to stamp, so an anchor that read a marker would mis-read every upgraded database. Option B would be a spec change the ADR does not ask for. The reachable widening is the DelegatedAdminGate path and pre-existing Default-Organization-scoped grants (①a), both bounded to a platform admin's own authoring, and under D3 both already held the environment's superuser set. Not escalated; the condition is that ② declares it.

Q2 — a multi-organization single deployment with no default slug fails closed: settled by D5 + D10 fate 4 + D9 together, not by D5's "no NULL grant row" clause alone. Keep A. D5 forbids the NULL row; D10 fate 4 forbids a guessed owner ("Never guessed"); D9 already refuses every derived-owner write on that topology, the boot only warns (auth-plugin.ts:850-855) and the tenancy census reports it at error with two remedies. Option B contradicts D5 outright. The card's "⛔ never a zero-admin boot" was written for the healthy topology — one organization, which the invariant creates — and on the ambiguous topology the two records collide; ruling A on #18707 makes D5 govern. The withheld message names the remedy (give the intended organization the slug default). Not escalated; the condition is that ② declares the narrowing, so an operator upgrading such a deployment is told before they lose their administrator.

The organization INSERT that moves the anchor is not guarded — flag, non-blocking, with a named remedy. The guard judges slug updates and deletes on sys_organization (last-admin-guard.ts:1833-1842) and, by its own pinned control, PERMITS re-slugging a SOLE organization ("a sole organization stays the Default Organization whatever its slug"). A sole organization with a non-default slug is also the ordinary shape of a 17.x single database that created its one organization before the invariant existed; the ceremony attributes the admin's grant to it. Inserting a second organization then (a platform admin's Setup insert on sys_organization — '*' allows create; denyWritesOnManagedObjects binds organization admins only) leaves several organizations and none default: the Default Organization is unnamed, the attributed grant stops anchoring, the resolver answers MEMBER silently, and the next boot's holder scan finds nobody and withholds promotion. On base the same sequence was harmless (the NULL row anchored regardless). The result is fail-closed (loss of standing, no escalation), the topology is one D9 already refuses and the census already reports at error, and the remedy is in the withheld message — so not blocking. Remedy for 8c-2 or a small follow-up card: refuse, under single, the re-slug of the sole organization away from default while it owns the anchoring grant (makes the state unreachable, one hook already registered), or hook beforeInsert on sys_organization under single when the sole organization is not slugged default. The PR body's sentence "the two organization writes that move the anchor" undercounts by this one.

Deviation — widened surface (last-admin-guard.ts, admin-standing-surface.ts): accepted. Without the guard change the promoted administrator's grant counted as no administrator, and the ban, revocation or re-scoping of the last one would have been waved through — a worse hole than the file-surface breach. The surface entry was forced by the f8eb736 correspondence gate. The claim (6251512145) said "stop on breach and explain in the report"; the dev did not stop, but explained, and the alternative (land the anchor without the guard) is not acceptable at this tier. Right call; the seat records the breach.

Deviation — ensureDefaultOrganization reuses a sole organization: accepted (①, narrowing 3). It aligns step 4 with the definition every other reader already used and prevents the bind from moving the anchor. Declared in ②; the golden move is recorded in the file.

Deviation — line budget (638 source + 425 test vs ~200 + ~400): accepted. The guard's three read sites, the two organization hooks and their copy account for it; 1,065 changed lines is far from the 3,000-line Tier H threshold.

A3 pin is one step removed from the ceremony (①c): the fresh-boot golden models the ceremony's output; the cli acceptance fixture carries no admin grant. Recommend 8c-2 (or a two-line follow-up) adds grant_admin with organization_id: null to organization-ownership-apply.test.ts's fixture and asserts the attributed owner equals what readDefaultOrganizationId answers — the real pin of the A3 defect the dispatch named "to be measured first".

Four spellings of the anchor predicate, seven of the Default Organization definition (①a, ①b): correct today, lockstep-edit risk for 8c-2, when the null arm retires everywhere at once. Recommend 8c-2 exports grantAnchorsPlatformStanding and readDefaultOrganizationId from @objectstack/core (plugin-auth and plugin-security already import from it) and retires the three plugin copies — a minor widening of core's surface, declared then.

Cross-lane pointers (not this PR's): packages/verify/src/rls.ts still writes NULL-owner persona rows (8b-0b's file, the dev noted it). Cloud's env-owner-admin-grant.ts legacy row keeps anchoring under single until 8c-2, which must wait on cloud's anchor move (#11977 rider; card comment 5521222826). The card's Constraints paragraph ("config now required for admin") remains the superseded residue the ruling did not name; the PR correctly requires no config under single.

Blocking items (the fix each needs):

  1. .changeset/11979-default-org-admin-grant.md:9 — Clause-②: no → Clause-②: yes (narrowing) (floor: no (narrowing)); :2-4 — patch → minor on all three packages; body — add the **BREAKING** … shipped as minor line, the three FROM → TO pairs with remedies and the widening sentence (②), and the single adr-0087: not-required (no-migration-prescription) … HTML-comment marker, then confirm pnpm check:adr-0087-registration and Check Changeset are green on the new head.
  2. PR body line 3 (Clause-②: no) and the "Acceptance notes" first bullet — the same arm and wording as the changeset; add the resolver's and guard's extra sys_organization reads (①d) to "What changes" so the cost is stated where the batch-equivalence sentence is.

Everything else in the diff is right as judged in ①; the non-blocking flags above carry their own remedies and carriers. A re-render is owed on the new head once the changeset and PR body are re-cut; the code paths need no change for this verdict to flip.

Implemented-by: claude/issue-11979-default-org-admin-grant
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

…s (narrowing), minor, with its FROM → TO pairs and ADR-0087 disposition

Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2af8fd548e43ae089975dfdc45e7e74507e290ed
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admin-standing-surface.ts, packages/core/src/security/admin-standing-surface.test.ts, packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts, packages/plugins/plugin-security/src/builtin-positions.boot.test.ts, packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts, packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/ensure-default-organization.ts, packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts, packages/plugins/plugin-auth/src/ensure-default-organization.test.ts, packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts, packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts, packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts, packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts, packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts + .changeset/11979-default-org-admin-grant.md (a prose face, widened in because ② judges it), packages/plugins/plugin-auth/src/index.ts, packages/plugins/plugin-auth/src/default-organization-invariant.ts, packages/plugins/plugin-auth/src/tenancy-service.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-security/src/suggested-audience-bindings.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/platform-objects/src/identity/sys-organization.object.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/utils/organization-ownership-plan.ts, docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D3, D5, D9, D10 and both amendments), docs/adr/0087-metadata-protocol-upgrade-contract.md (the disposition table), AGENTS.md:1084-1101, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .github/workflows/pr-automation.yml:940-1000

Rendered by an isolated at-tier reviewer subagent of the adopting epic seat (epic:#15194), round 2, on the current head. gh api …/pulls/24373 answered head.sha = 2af8fd548e43ae089975dfdc45e7e74507e290ed, the sha the brief named: the head had not moved (open, draft, against main, 23 files, +918/−156 against origin/main 5da2e888, merge-base a2a3777b). Inputs: card #11979's body and all ten comments (the two rulings, the dispatch claim 6251512145, the os-dev-report 6252609396, its patch-round addendum 6253168890), ADR-0131 D3/D5/D9/D10 with the 2026-09-17 and 2026-10-16 amendments on origin/main, plan 6251466182 on #15212 with its three amendments (6251607592, 6252974640, 6253205661 — the last re-grades row 8b-0a to yes (narrowing) and gives 8c-2 three carriers), the PR body, its file list, both PR comments (the docs-drift bot and the FAIL record 6253115144), the net diff origin/main...2af8fd54, and the head's 47 check-runs. Read-only shape held: gh api reads, git fetch into refs/review/pr-24373-r2, git diff/show/grep/log on that ref, and the brief-prescribed node scripts/pm/record-recognisers.mjs --template --face narrowing. Nothing built, run, tested or re-gated. The Check Changeset job log itself was not readable from this container (the log blob answered 403), so the gate readings below are the check-run conclusions plus this record's own reading of the gate scripts against the committed text.

What moved since 6253115144, verified: git diff bc90a53a..2af8fd54 touches ONE file, .changeset/11979-default-org-admin-grant.md (+13/−4): the three bumps patch → minor; Clause-②: no → Clause-②: yes (narrowing); one HTML-comment marker adr-0087: not-required (no-migration-prescription) … with a 700-character justification; the line **BREAKING** (an accept-set narrowing), shipped as minor under the launch-window convention; three **FROM** … **TO:** … **Fix:** bullets and one **Widening** bullet. No source or test file moved, so every ① judgment of 6253115144 is re-judged on identical code below, and the dev's test and gate readings at bc90a53a carry to this head for the code (the changeset is CI's to judge, and CI judged it — next paragraph).

Check-runs on the head: 47, all completed; 39 success, 8 skipped, 0 failure, 0 in progress — read at the start and re-read before this record was finished. The five required contexts are green: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Build Core, Governed Surface Queue Guard. Check Changeset is green on both runs of the head (39151491958 and 39151539736); that job is where check-adr-0087-registration.mjs --base $MERGE_BASE and check-changeset-no-major.mjs --base $MERGE_BASE run on a PR (pr-automation.yml:976-977, :1127), so the two gates whose verdicts ② turns on have judged this changeset AS WRITTEN — with the arm present — and passed it; on the previous head they had judged a declaration that asked them nothing. The skipped eight are path-filtered or opt-in lanes (Build Docs, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the Dogfood matrix shard, Packed-tarball smoke, and the second Auto Label / Check PR Size runs). Check PR Size: 1,074 changed lines, under the 3,000-line Tier H threshold. Docs Drift Check (bot comment 6252607424) is advisory: 33 hand-written and 12 release-owned pages name touched symbols; release-owned pages are compiled at release time from this changeset (AGENTS.md Documentation Guardrails), which is one reason the changeset text had to be right.

Face. No packages/spec/src/** path and no governed path, so reviewFace returns no hits and the changeset's Clause-②: line decides: yes (narrowing) ⇒ the narrowing face ⇒ CONTRACT_REVIEW_TIER, whose Scope: floor is every path of the diff outside the prose faces. Plan 6251466182 assigns 8b-0a to this tier by name ("platform standing") as well. Scope: above lists the 22 code and test paths as the floor and the cited files after the +.

① Derived judgments

Every judgment is on the same code 6253115144 read; re-read at head line numbers and re-judged, not inherited.

(a) grantAnchorsPlatformStanding — right on D5; one derivation site with the posture as its parameter; a bounded, now-declared widening. packages/core/src/security/resolve-authz-context.ts:912-920: walled → false; else owner === null || (defaultOrganizationId !== undefined && owner === defaultOrganizationId). Read at one place, :1497-1510: the posture from postureEnforcesWall(resolveTenancyPosture()) (the environment, no query), the Default Organization from readDefaultOrganizationId (:934-942) only when !walled and the principal holds an active admin_full_access grant with a non-null owner (:1498-1501). The anchoring grant joins applyingGrants in every organization (:1508-1510, grantAppliesInTenant(...) || anchorsPlatformStanding(r)), as the organization-less row always did (grantAppliesInTenant(null, …) is true, :861-863); the rung is set only for an anchoring grant whose set is active (:1602, anchorsPlatformStanding(grant) && active), so ADR-0049 deactivation still confers nothing. Pinned on every arm in resolve-authz-context.platform-admin-config.test.ts (D5 suite: in the Default Organization and with no active organization; a sole organization of any slug; another organization's grant confers the set and MEMBER; several organizations and none default anchor nothing; a walled rig derives nothing and never reads sys_organization; the organization-less row still anchors with no organization read). The card's Constraint — the walled/single derivation is ONE code path with a posture parameter, not two sites — is met. The accept-set widening is real and bounded: a pre-existing Default-Organization-scoped admin_full_access grant (a 17.x platform admin's Setup write, or a manageAssignments delegated scope whose assignablePermissionSets names admin_full_access, delegated-admin-gate.ts:799-828 — both authored under platform-admin authority) gains PLATFORM_ADMIN on upgrade; under D3 "the organization is the environment" under single, so it already held the environment's superuser set. Right, and now declared as the widening (②).

(b) The Default Organization definition — seven copies, one definition; the resolver's and the guard's are read-only. Re-read at head: core readDefaultOrganizationId (:934-942), the guard's (last-admin-guard.ts:973-1005, judged on the after-image of a pending sys_organization write), plugin-security resolveDefaultOrganizationId (suggested-audience-bindings.ts:549-558, what the promotion resolves through, bootstrap-platform-admin.ts:713-719 and :807), plugin-auth resolveDefaultOrgId (tenancy-service.ts:225-231), ensureDefaultOrganizationExists (default-organization-invariant.ts:87-93, the creator), findDefaultOrganizationId (ensure-default-organization.ts:341-346), and the ceremony plan (organization-ownership-plan.ts:1304-1324, DEFAULT_ORGANIZATION_SLUG :493). Every copy: slug = 'default', else the row when exactly one exists (limit 2, length === 1), else none. sys_organization carries no tenant column (sys-organization.object.ts; the resolver's tryFind reads it installation-wide under isSystem), so the answer cannot vary by caller. They differ only on a FAILED read: core's tryFind throws AuthzStoreUnavailableError on an unreachable store and answers [] on an unprovisioned table (:350-400); plugin-security's throws and readPromotionGrantOwner catches to undefined → promotion withheld; tenancy-service folds to null; the invariant propagates and fails the boot. On a reachable, provisioned store all seven agree on every topology (0 / 1 / several-with-default / several-without). Right. Consolidation is 8c-2's (plan amendment 6253205661 item 3), on layering: core cannot import plugins.

(c) The A3 fix — right, and the live defect it fixes is real on base. The ceremony attributes a single database's organization-less sys_user_permission_set rows to the Default Organization with no exemption for the promoted admin's grant (organization-ownership-inventory.ts:567: fate attribution, anchors: [], D10_SINGLE; the owner from organization-ownership-plan.ts:1304-1324, the same definition as (b)). On base the resolver read !ownGrant only and the holder scan asked organization_id: null only, so after --apply the administrator lost PLATFORM_ADMIN and the next boot's scan found nobody and minted a second grant. On this head the resolver anchors the attributed row, and the holder scan's leg A asks both owners, each ordered by id, the row whose id sorts first winning (bootstrap-platform-admin.ts:607-618); legs B and C apply the same isAnchorHumanHolder predicate (:585-587, :642, :651). Pinned: the fresh-boot golden "an upgraded database whose administrator's grant the ceremony gave the Default Organization promotes nobody else" (fails on base, per the dev's reverse verification). The pin models the ceremony's output shape by hand; the real pin against organization-ownership-apply.test.ts's fixture is 8c-2's carrier 2 (6253205661). Right.

(d) The cost of readDefaultOrganizationId on the authorization path — acceptable, and now stated where it belongs. Resolver: at most once per resolveUserAuthzGrants, only for a principal holding an active organization-owned admin_full_access grant under single (:1498-1501): one indexed read when a default-slugged organization exists (the normal post-PR single deployment), two otherwise; deduplicated within a request by openRequestGrantsMemo (:1295-1296) and cached across requests only when OS_AUTHZ_GRANTS_CACHE_TTL_MS is set. Every other principal issues exactly the reads it issued before, which is why the batch-equivalence goldens (whose fixture holds the organization-less row) do not move. Guard: anchorOwnerTest (:1017-1030) adds one or two sys_organization reads per enumeration whenever some admin_full_access grant names an owner; enforceStanding (:1539-1632) enumerates two or three times per guarded write plus the emptied-vs-fresh (:1295) and dangling (:1327) reads — about three to six small reads per guarded write, on writes that are rare and already pay comparable reads. PR body "What changes" item 4 and the changeset's widening bullet now state both figures. Right.

Every other accept-set, permission and surface change the diff implies, named:

  • The promotion writes the Default Organization, never NULL (bootstrap-platform-admin.ts:1113, organization_id: defaultOrganizationId; owner read at :807, undefined under a wall where nothing is written). D5's sentence made true for every new promotion. Golden pins ['usr_first', 'admin_full_access', 'org_default']. Right.
  • Promotion withheld when no Default Organization can be named (:1092-1107: one error line naming the remedy, reason: 'default_organization_unresolved', no insert). D5 forbids the NULL row; D10 fate 4 forbids a guessed owner. The withheld set is "no default-slugged organization and not exactly one organization, or the read was refused" (the message says so); on a single composition the invariant creates the organization before kernel:ready or fails the boot (default-organization-invariant.ts), so the reachable population is the several-organizations-none-default topology the FROM → TO pair names. bootstrapPlatformAdmin's return is reason?: string (:732), so the new value is not a type change. Right in behaviour; declared (② pair 1).
  • Holder scan: two owners, deterministic winner; a user holding both rows is one holder. Right.
  • Resolver: another organization's grant confers the set in that organization, never standing; under a wall nothing anchors and sys_organization is never read; the organization-less row still anchors under single (D5 "until D10's migration clears it"; the null arm's retirement is 8c-2's, after [Decision] C8: a certified database's leftover organization-less rows — block certification, or go dark when the NULL arms retire #24352 — ruled C, 6251607592 — and cloud's anchor move). Right, and the walled posture is byte-for-byte unchanged in effect.
  • Last-admin guard (last-admin-guard.ts): enumeration step 1 counts the holder of the Default Organization's grant (:1093-1097, :1119) and still runs on a non-walled posture only (:1088-1091, adminSetInEffect = !legacyGrantAnchorRetired && …); the emptied-vs-fresh evidence (:1295-1296) and the switched-off-set dangling read (:1327, :1339) read the same anchor; beforeUpdate on sys_organization judged only when the payload touches slug (:1768-1774, touchesAny against STANDING_KEYS_BY_TABLE.sys_organization), beforeDelete on every row (:1776-1780), both at priority 20 (:1833-1842); a by-where/multi write resolves its target set through resolveTargetIds (:1413-1434) and is judged on the after-image via applyPending; a failed lookup refuses (failClosed). Consequence for the holder: banning, revoking or re-scoping the Default Organization's grant holder is refused when they are the last unbanned administrator — the restoration of the invariant for a database the ceremony has run on, where base waved those writes through. Pinned in last-admin-guard.default-organization.test.ts with controls (another organization's grant; a sole organization's slug or name change). Right. One write that moves the anchor stays unhooked — the INSERT of a second organization beside a sole one not slugged default — carried (③).
  • admin-standing-surface.ts:154-167: sys_organization as derives, columns id and slug; last-admin-standing-keys.test.ts pins STANDING_KEYS_BY_TABLE.sys_organization to ['slug'] and the f8eb736 correspondence gate is green. Right.
  • ensureDefaultOrganization: step 2 reads the Default Organization's grant beside the organization-less one (:503-512); step 4 reuses the Default Organization by the invariant's definition under single and only the default-slugged one under a wall (:557-560). The golden move in grant-readers-by-name.golden.test.ts (SINGLE owners ['usr_admin'] → ['usr_admin','usr_orgadmin']) is recorded in the file with its reason. Right in behaviour; declared (② pair 3).
  • Public surface — one precision the body does not carry. grantAnchorsPlatformStanding, readDefaultOrganizationId (core) and ORGANIZATION_STANDING_KEYS (plugin-auth) are module-private, as the changeset says. But STANDING_KEYS_BY_TABLE (:777-784) and STANDING_KEY_EXCLUSIONS (:836-842) are exported, and packages/plugins/plugin-auth/src/index.ts:47 re-exports the whole module (export * from './last-admin-guard.js'), so two values on plugin-auth's package entry each gain a sys_organization key. Their declared types (a readonly string-keyed record) are unchanged and nothing is removed or renamed, so this is an additive value change already covered by minor and by Clause-②: yes; the PR body's "no … package entry changes" is true of the export LIST and over-states slightly for these two values. Non-blocking precision; no text change required.
  • Test fixtures now seed the Default Organization a single boot creates before kernel:ready; the holder-scan fake treats the Default Organization's leg like the NULL leg; the truncation warning's wording moves from "unscoped" to "platform-admin". Right.
  • Cloud's legacy row (card comment 5521222826): still anchors under single on this head, so no cloud environment owner is locked out by this PR; its retirement waits on cloud's anchor move (platform-admin re-anchor L8 (cloud seam): control-plane env injection + invite-only interaction — cloud seat leg, designed blind #11977), recorded on row 8c-2 (6253205661). Right.

② Semver level

The declaration, the bumps, the banner, the pairs and the disposition now match what the diff publishes, and the two gates that read them concluded success on this head.

  • Clause-②: yes (narrowing) is the right arm. scripts/pm/clause2-line.mjs:91-99 is the closed set: yes / yes (widening) a widening; yes (narrowing) "a diff that widens one surface and narrows another. Both facts are true and both are read"; no (narrowing) not a widening but breaking; no (widening) malformed. This diff widens the platform-standing accept set (①a) and narrows three surfaces (below), so yes (narrowing) is the arm, not no (narrowing) (6253115144 named that as the floor only if the widening were read as a mere replacement; ①a shows it is not). The line sits at .changeset/11979-default-org-admin-grant.md:9 in the fixed spelling (CLAUSE2_KEY_LINE), key-initial, value first, arm parenthetical first — readClause2Line reads { declared, yes, narrowing }. PR body line 3 carries the same line and the Acceptance-notes first bullet the same arm and wording (check-changeset-no-major's level axis reads the PR body's declaration from the event payload). AGENTS.md:1087-1088: "yes takes at least minor, (narrowing) is BREAKING" — both honoured.
  • The FROM → TO pairs are complete against the earlier record and true against the code, each with its one-line fix (AGENTS.md:1089-1092; .changeset/…:15-17): (1) plugin-security — several organizations, none slugged default: promoted with an organization-less grant on base (organization_id: null, base :1024) → not promoted, one error line, reason: 'default_organization_unresolved', no row (head :1092-1107); fix: slug the intended organization default (or keep one, or declare a wall); the next kernel:ready or sign-up pass promotes (shouldReplayBootstrapFor, :453-460). True. (2) plugin-auth — a sys_organization slug change or delete was never refused on base → refused 403 PERMISSION_DENIED when that organization is the Default Organization whose grant anchors the last unbanned administrator (:1768-1780, :1833-1842; enforceStanding refuses only when losing is non-empty and unbannedAfter is empty, so organization owners, config-anchored administrators and other anchor holders all count as survivors); fix: give another account administrator standing first. True. (3) plugin-auth — ensureDefaultOrganization under single with a sole organization of another slug created a second default-slugged one on base (tryFind(slug:'default') else insert) → reuses the sole one (:557-560); fix: none needed. True. The Widening bullet (:18) states the core widening with its read cost; it is true (①a, ①d). The three pairs are exactly the three narrowings the diff performs (and the three 6253115144 required); the refusal of the ban/revocation/re-scoping of the Default Organization's grant holder is the widening's own consequence and is stated in the narrative bullet (:23). No pair is missing.
  • The ADR-0087 marker is the right disposition and is spelled as the gate reads it. .changeset/…:11: one HTML-comment marker whose text opens adr-0087: not-required (no-migration-prescription) followed by the justification. readDisposition (check-adr-0087-registration.mjs:1990-2014) finds exactly one marker, parses not-required, category no-migration-prescription (in CATEGORIES, :536-543, and in ADR-0087's table), ids none, why far above the 40-character floor (:4917-4923). breakingDeclaration (:678-686) reads two signals, BREAKING (the **BREAKING** banner, :13) and clause-②-narrowing (through the imported readClause2Line), so the changeset IS judged, as a newly added (A) row. The category's one re-validation is the prescription detector (:5010-5012, findMigrationPrescription, :1861-1960): read against the body, branch 1's label pattern finds no FROM … arrow … TO on one line and no line-initial **FROM** ( (each pair opens with the list bullet - , which the second alternative does not admit); branches 2–4 need an arrow (the → glyph, or a hyphen or em-dash followed by a closing angle bracket) between two operands or a framed table, and the body carries none — so the detector returns null and the exemption is admitted, which is what Check Changeset's success says. Right in substance too: no spec key, export, option, error code or stored shape is added, removed or renamed; the only stored-shape move (NULL → Default Organization on the grant row) is D10's operator-run ceremony, outside objectstack migrate meta's ledger; the other five categories are closed on facts the justification states (the packages publish; no ADR-0087 id is touched; no packages/spec schema moves; nothing exported is retired).
  • minor in the launch window is right. check-changeset-no-major.mjs:58-60, 74-85: breaking changes ship as minor until GA, the banner and the ADR-0087 disposition being the carriers; :1631-1632 and :1670 read the narrowing arm as "a BREAKING change; during the launch window it ships minor"; the level axis ([finding] Check Changeset's clause-② level rule is PR-scoped but the fact it judges is package-scoped — a PR that widens package A is refused for grading package B patch, where B only received a comment #16361) requires at least one moved package graded minor or above for a yes declaration, and all three moved packages (packages/core, plugin-security, plugin-auth, each with src/** in the diff) are minor (.changeset/…:2-4). No major. Check Changeset green on both runs.
  • Plan row 8b-0a read "no (the dev measures)"; amendment 6253205661 re-graded it to yes (narrowing), BREAKING, minor, with an ADR-0087 disposition — the record now matches the changeset.

No wrong text remains in the changeset or the PR body. One optional precision, not a blocker: the PR body's "No spec, REST answer or package entry changes" could read "no spec, REST answer or export-list changes" (①, the two exported maps gain a key).

③ Boundary flags

Every dev flag and open_questions entry, from the report 6252609396 and the addendum 6253168890, and every deviation 6253115144 accepted, re-judged on this head.

Q1 (report) — every Default-Organization admin_full_access grant confers PLATFORM_ADMIN under single; accept, or mark the promotion row? Accept A; it still holds. D5 owns the promotion's row by the Default Organization; D3 makes that organization the environment under single; D10 fate 3 produces exactly this row shape for every attributed grant with no column to stamp, so an anchor reading a marker would mis-read every upgraded database. Option B is a spec change no record asks for and a new write surface. The reachable widening is bounded to platform-admin-authored authority (①a) and is now declared as the widening (②). Not escalated.

Q2 (report) — a multi-organization single deployment with no default slug fails closed; keep? Keep A; it still holds. D5 forbids the NULL row, D10 fate 4 forbids a guessed owner, D9 already refuses every derived-owner write on that topology, the boot only warns on ambiguous (auth-plugin.ts:850-855) and the census reports it at error. Option B contradicts D5. The card's "never a zero-admin boot" was written for the healthy topology, which the invariant creates; on the ambiguous one ruling A on #18707 makes D5 govern, and the withheld message names the remedy. Now declared as narrowing 1 (②), so an upgrading operator is told. Not escalated.

Deviation — file surface widened beyond the claim (last-admin-guard.ts, admin-standing-surface.ts): accepted, as in 6253115144; still holds. Without the guard change the promoted administrator counted as nobody and the ban, revocation or re-scoping of the last one was waved through; the surface entry was forced by the correspondence gate. The claim's "stop on breach and explain" was discharged by the explanation; the alternative was not acceptable at this tier. The seat has the breach on record.

Deviation — ensureDefaultOrganization reuses a sole organization: accepted; now declared (② pair 3).

Deviation — line budget (638 source + 425 test vs about 200 + 400): accepted; 1,074 changed lines on the head, under the 3,000-line Tier H threshold; the overrun is the guard's three read sites, two hooks and their copy.

Deviation — worktree left detached and re-attached; harness trailer replaced by AGENTS.md's model-free pair: process notes with no bearing on the diff. Verified on the head's seven non-merge commits: each ends with Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf and a Co-authored-by: Claude trailer carrying the noreply@anthropic.com address, no model identifier. PR body footer is the session-URL form the PR-body surface keeps.

Addendum deviation — PR body rewritten through the relay's issue_patch on the PR number, read back byte-identical: fine; the live body carries Clause-②: yes (narrowing) at line 3, the read-cost item 4 and the matching Acceptance-notes bullet, as read this round. Addendum open_questions: none.

Out-of-scope findings the dev carried, each with its carrier on this head:

Card residue: the Constraints paragraph's "config now required for admin" is the superseded clause the ruling did not name (card body, comment 5725699684); the PR requires no configuration under single and says so. Comment 5536463173's warning — do not implement 4B on D5's strength — is honoured: the PR implements ruling A (D5), which reversed 4B.

Acceptance criterion, as rewritten under ruling A: a production-mode single boot with no configuration still promotes the first user (fresh-boot golden; dogfood boot log "first user promoted to platform admin" ×3 at bc90a53a), the grant it writes is owned by the Default Organization (golden pins org_default, never NULL), and the derivation is one code path with a posture parameter (①a). Met on this head; this PR is the C3 remainder plan 6251466182 moved into 8b-0a, so D5's "once C3 has landed" clause becomes true for every new promotion with it, while pre-existing NULL rows remain D10's to clear.

Blocking items: none. The two items 6253115144 listed are discharged on this head: the changeset carries yes (narrowing), three minor bumps, the BREAKING line, the three pairs with fixes, the widening sentence and one not-required (no-migration-prescription) marker, and the PR body carries the same arm and the read-cost statement; Check Changeset and every required context are green on 2af8fd54. Non-blocking carriers are recorded on row 8c-2 by amendment 6253205661.

Implemented-by: claude/issue-11979-default-org-admin-grant
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

This was referenced Oct 18, 2026
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.

platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback

2 participants