Skip to content

fix(core,plugin-auth)!: the last-admin guard judges a sys_organization insert that moves the Default Organization; one Default Organization definition - #25265

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25208-8c2-carriers
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25208-8c2-carriers

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25208

Clause-②: yes (narrowing)
Metadata-protocol: none

The three 8c-2 review carriers of record 6253205661 (owed scope of #15212, claim 6345294692).

1. The anchor guard: measured, then a beforeInsert half

Measurement first, on main at e58bd0e3. I used a real ObjectQL engine over better-sqlite3, with the real last-admin guard and the real resolveAuthzContext. The single posture has a sole organization slugged acme, and an admin_full_access grant owned by it is the only standing of usr_admin.

write before after
(A) insert a second organization, slug b usr_admin is platform_admin the insert is admitted; usr_admin is no longer platform_admin; a ban of usr_admin is then admitted (the guard counts nobody, so it reads the bootstrap window)
(B) insert an organization slugged default, then a grant of the set owned by it to usr_b usr_admin admin, usr_b not the insert is admitted; usr_admin loses standing; usr_b gains it

So the anchor can move. (A) is the fail-open the review named: the silent loss also disarms the guard for the next write.

What the code and ADR-0131 D5 support: refuse, judged like the update and delete halves. D5 makes the Default Organization's grant the anchor under single. D3/D5 define the Default Organization as the one slugged default, else the only one. So keeping the anchor fixed by writing to an organization on the caller's behalf is not available: re-slugging a row the caller did not touch would be a write nobody asked for. The guard is a break-glass invariant (ADR-0135 D5.2). Its update and delete halves already refuse a Default Organization re-slug or delete only when no administrator who can sign in would remain. The insert half asks the same question with the same enumerate, simulate, enumerate machinery, so it cannot drift from them:

  • guardOrganizationInsert is registered on beforeInsert of sys_organization, priority 20, with the guard's package.
  • The simulation adds the inserted row to both reads of the Default Organization definition (PendingStandingWrite.inserted, as the activation-ledger insert half does). A payload with no id yet is simulated under a placeholder id that no grant can name.
  • It refuses PERMISSION_DENIED / 403 on sys_organization. The refusal says what moved and names the remedy: give the current Default Organization the slug default first (while it is the only one, that changes nothing else), or grant another account standing.
  • It admits when an organization owner or admin, an OS_PLATFORM_OWNER_EMAIL administrator, or a default-slugged Default Organization remains. ⛔ It is not standing preservation. A platform administrator who also owns an organization keeps that standing, which the invariant counts, so the insert is admitted and the grant stops anchoring. That is the same verdict the update half gives a re-slug.
  • Under a wall no grant anchors, so the half returns before any read.

Reach, measured through os serve (production, single). Both public create doors already refuse a second organization: better-auth POST /auth/organization/create answers 403 ORGANIZATION_CREATION_DISABLED, and POST /api/v1/data/sys_organization answers 405 OBJECT_API_METHOD_NOT_ALLOWED (that object's allowed list still names import, which I did not measure). So the half guards the writers below HTTP: seeds, scripts, a host's own engine calls and imports.

2. The ceremony pin

The acceptance fixture of organization-ownership-apply.test.ts now holds grant_promoted: an admin_full_access grant with a NULL organization_id, in the 17.x shape (by id to an admin_full_access mirror row). The test reads readDefaultOrganizationId (@objectstack/core) over the same SQLite database before the ceremony. It pins that the grant is attributed to that answer (org_default), and that under a wall it stays unattributed.

Two fixture consequences:

  • The catalog double now declares admin_full_access, as the composed security plugin ships it.
  • The "name only the uncomposed security plugin declares" block asks for a catalog without it (catalog({ adminSetDeclared: false })), which is its premise. The walled test's sys_permission_set count moves 3 to 4 (the extra mirror, not deleted under a wall).

3. One Default Organization definition

@objectstack/core exports the rule once, plus the resolver's read:

  • findDefaultOrganization(read): slug = 'default', else the only organization, else none. The answer reports several too. It is parameterised by the read, because the readers differ on purpose in how they read, never in what they pick.
  • DEFAULT_ORGANIZATION_SLUG.
  • readDefaultOrganizationId(ql): the resolver's former private function, now exported. Its read fails loud on an unreachable store and reads a never-provisioned one as none.
copy now kept on purpose
resolve-authz-context.ts (readDefaultOrganizationId) the rule over tryFind, exported the read policy (AuthzStoreUnavailableError, registry check)
default-organization-invariant.ts the rule over a read that propagates the producer half (create, ambiguous report)
ensure-default-organization.ts (plugin-auth) the rule over its swallowing tryFind the walled branch reads only slug = 'default'; under a wall no sole-organization leg applies (comment in place)
last-admin-guard.ts the rule over the guard's own read the simulation of a pending write (now also an insert) and scan's fail-closed ceiling, which is why it cannot call the resolver's read
tenancy-service.ts (resolveDefaultOrgId) the rule over findRows answers null, and a failed read reads as none
suggested-audience-bindings.ts (resolveDefaultOrganizationId) the rule over its read scopedSystemCtx(), and a failed read throws (the promotion decides)
organization-ownership-plan.ts the rule over raw SQL no engine; a store with no slug column has no default to find

Two edge answers move to the resolver's:

  • In the ceremony's plan, a default-slugged row with a blank id now falls through to the only-organization reading. It used to name nothing.
  • In plugin-security's reader, a numeric id is read as its string. It used to be read as no id.

Tests and proof

All runs are at 6f6e9003 (after merging main) unless noted.

  • New pins:
    • last-admin-guard.default-organization.test.ts: 6 cases, 15/15 in the file.
    • default-organization.test.ts (core): 8.
    • The apply-test pin: the file is 112/112.
  • Ablations (scripts/ablation-replace.mjs, each restored to the HEAD blob, git diff HEAD empty):
    • Remove the insert half's enforceStanding: 3 pins red.
    • Remove the wall short-circuit: the walled control red.
    • Drop the inserted row from the simulation: 3 pins red.
    • Core: sole leg === 1 changed to >= 1: 2 red.
    • The plan naming another organization: the acceptance test red. Its effective-access assertion (:584) fails ahead of the new pin, because the same attribution moves that grant's access line.
  • Suites:
    • At 2c165f8b: plugin-auth 185 files, 3493 passed. plugin-security 208 files, 4274 passed. core 110 files, 2638 passed. Each part exited 0.
    • After the merge: the scoped files 292 + 130 + 8 + 112, joined with &&, exit 0. typecheck of core, plugin-auth, plugin-security and cli: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands at 2c165f8b:
    • 76 derived. 74 exit 0 (the three i18n gates after building their prerequisite).
    • 2 exit 3, NOT MEASURED: check-plugin-teardown-shape --self-test needs an unshallow clone; check:dual-build-cjs-loads needs the full build.
    • --ran, with exit codes recorded: 76 accounted. check-changeset-fixed and check:optional-error-sink exit 0.
  • check-governed-merges --branch: 0 of 13 paths governed. 633 changed lines (+516/−117). Not Tier H or S.
  • R1 (fresh boot): a production os serve of examples/app-crm under single, on a fresh SQLite file: health 200, sign-up 200, sign-in 200, session 200. The harness's fresh-store leg passes too.
  • R4 (walled): a production os serve of examples/app-crm under isolated and under group. @objectstack/organizations was declared in the app for the run and reverted after. Both: health 200, sign-up 200, sign-in 200, two organizations created through POST /auth/organization/create (200 each, through the new hook's wall path), session 200, 0 organization-less grants.
  • Upgrade harness (upgrade-18x-harness.dogfood.test.ts, both fixture legs, 18.0 and 18.0.0, plus the fresh store): 16/16.
  • Full dogfood battery: running on 6f6e9003 when this body was written. The result is in the os-dev-report on fix(core,plugin-security): ADR-0131 8c-2 review carriers — guard a sys_organization insert that moves the platform-admin anchor, pin the ceremony's NULL admin_full_access attribution, one Default Organization definition #25208.

Acceptance notes

  • The admin_full_access anchor predicate stays in two places. The guard's anchorOwnerTest and the resolver's grantAnchorsPlatformStanding each spell it. Record 6253205661 proposed exporting it with the definition. This card's surface is the Default Organization definition, and the guard's form reads the simulated Default Organization, so it is not the same function.
  • The ambiguous.organizationCount field is always 2. That is what the boot invariant could measure: the rule reads at most two rows. plugin-auth's boot warning therefore says "holds 2 organizations" for any number above one. This is unchanged and pre-existing.
  • Not measured: whether import on sys_organization (listed as allowed by the data API under single) reaches the engine's beforeInsert.

Generated by Claude Code

…efinition; the last-admin guard judges a sys_organization insert

Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV
Co-authored-by: Claude <noreply@anthropic.com>
…eremony's attribution of a NULL admin_full_access grant

Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security, touching 21 documentable anchor(s).

27 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 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c.

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

What this run could not see
  • 9 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 — 60 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 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c

⚠️ 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 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c → 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: 6f6e90033c775667aeb3a6bb20fa88d74f1cfd08
Local-runs: none
Scope: packages/cli/src/utils/organization-ownership-apply.test.ts, packages/cli/src/utils/organization-ownership-plan.ts, packages/core/src/security/default-organization.test.ts, packages/core/src/security/default-organization.ts, packages/core/src/security/index.ts, packages/core/src/security/resolve-authz-context.ts, packages/plugins/plugin-auth/src/default-organization-invariant.ts, packages/plugins/plugin-auth/src/ensure-default-organization.ts, packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/tenancy-service.ts, packages/plugins/plugin-security/src/suggested-audience-bindings.ts + packages/core/src/index.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/identity-objects-plugin.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, packages/platform-objects/src/identity/sys-organization.object.ts, packages/platform-objects/src/identity/sys-organization-update-door.test.ts, packages/spec/src/data/api-derivation.ts, packages/rest/src/data-action-routes.ts, packages/objectql/src/engine.ts, packages/metadata-protocol/src/seed-loader.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, .github/workflows/upgrade-harness-optin.yml, .github/workflows/pr-automation.yml, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .changeset/24883-last-admin-guard-default-organization-anchor.md, docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D3, D5, D9, D10), docs/adr/0135-sso-scim-per-environment.md (D5.2). The changeset .changeset/25208-default-organization-one-definition.md is judged under ② as prose.

Inputs read: card #25208 (body, claim 6345294692, os-dev-report 6348218751); record 6253205661 on #15212; PR #25265 body and its 13-file list; the net diff 0b8bce40e0..6f6e90033c (13 files, +516/−117; commits 4be9398, 40622bd, 2c165f8 and the merge 6f6e900 of main); the 46 check-runs on the head. origin/main is 284e04810a, two commits past the merge-base (0f1eeeeeb7 #25259, 284e04810a #25258): neither touches any of the 13 paths, so the net diff is current. Check-runs: every completed run on the head concluded success or skipped; none failed, none pending — Lint & Repo Gates, TypeScript Type Check, Build Core, Test Core (6/6), Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, the three single-writer/claim guards, all success. The 18.x upgrade harness (opt-in) lane has two runs on this head: workflow run 39826380644 (created 17:51:54Z, the pull_request event that fired before needs:upgrade-harness landed at 17:51:57Z) concluded skipped by the workflow's own label condition, and run 39826393590 (the labeled event, created 17:52:00Z, completed 18:00:16Z) concluded success. The lane's verdict on this head is success; the skipped twin is the pre-label run, not a measurement.

① Derived judgments

1. The sys_organization beforeInsert guard — right.

  • Registration: last-admin-guard.ts registers guardOrganizationInsert on beforeInsert of sys_organization, priority 20, the guard's own packageId, beside the update and delete halves. engine.ts (16781–16820) dispatches beforeInsert once per row of a batch (dispatch.mode: 'per-row'), so ctx.input.data is one row and the docblock's batch claim holds.
  • What it refuses, exactly: on a non-walled posture, an insert that — simulated into both reads of the Default Organization definition — takes admin_full_access-anchored platform standing from every unbanned administrator with nobody left (enforceStanding: losing non-empty AND unbannedAfter empty). Under single that is: a second organization of any slug beside a sole organization not slugged default, or a new organization slugged default beside a sole one with another slug — and only when the sole organization's grant holders have no owner/admin membership, no OS_PLATFORM_OWNER_EMAIL administrator exists, and not every administrator is already banned. Admitted: a default-slugged Default Organization that stays put (the slug read still names it), any surviving membership standing, any configured administrator, and every write under a wall.
  • The rule under ADR-0131 D5: right. D5 makes the Default Organization's grant the single anchor, D3/D5 define that organization by slug else sole-ness, and the ADR's 2026-10-22 D5 note (seat decision 6324268472) says that with no nameable Default Organization no grant anchors — so the resolver's answer can move with no grant touched, which is the fail-open 6253205661 carrier 1 named (and the loss also disarms the guard: before.size === 0 reads as the bootstrap window; the dev's measurement (A) reproduced exactly that). The guard's charter is ADR-0135 D5.2's break-glass invariant ("an environment may never be left with zero administrators who can sign in"), and the update and delete halves (measure(plugin-auth): the organization-less admin_full_access anchor in the last-admin guard and the pre-organization default-org bootstrap, after ADR-0131 step 7 #24883, [Decision] Deleting an organization once organization_id is NOT NULL: refuse while it still owns records, or delete them with it #24948) already judge the Default Organization re-slug and delete by that question. The insert half asks the same question with the same enumerate–simulate–enumerate machinery, so the three halves cannot drift. A stricter standing-preservation rule would refuse legitimate growth of a deployment that has another administrator; fixing the anchor by re-slugging a row the caller did not touch would be a write nobody asked for. The refusal's remedy names the two real operator moves (slug the sole organization default first, or grant another account standing), and a pin shows the first makes the insert admitted.
  • The boundary this leaves, stated by the dev and pinned by the CONTROL "an administrator left standing elsewhere admits the insert": in the common production shape the first administrator is also bound as owner of the Default Organization by ensureDefaultOrganization, so the insert is admitted and the grant stops anchoring silently — platform standing drops while organization standing remains. That is the same verdict the update half gives a re-slug, consistent with ADR-0135 D5.2, and the BREAKING banner names exactly the narrower population the refusal reaches. Judged right under the ADRs as they stand; recorded in ③ as a design question, not a defect.
  • Legitimate inserts, each checked in code: (a) the boot invariant ensureDefaultOrganizationExists inserts only when found.id is undefined and not several — zero organizations. In AuthPlugin.start() it runs (line 886) before registerLastAdminGuard (line 1695), so a fresh boot's insert is not judged at all; were it judged, zero organizations means no grant anchors, before is empty, the inserted row owns no grant, and refuseIfEmptiedRatherThanFresh reads dangling grants only through anchorOwnerTest with no Default Organization, so it returns as fresh: admitted. (b) ensureDefaultOrganization's tryInsert of a default-slugged row runs only when no Default Organization is nameable (zero, or several non-default): before it no grant anchors, after it the new row owns none, losing is empty: admitted. (c) Seed replay: seed-loader.ts writes no sys_organization row (it resolves organization_id references); an application seed that carries organizations loads after the boot invariant under single (D3), so the Default Organization is slugged default and stays put: admitted. (d) A walled create: guardOrganizationInsert returns on postureEnforcesWall(resolveTenancyPosture()) before enforceStanding; the CONTROL pins zero engine.find calls; the dev's R4 drove two POST /auth/organization/create through it under isolated and group. (e) The ceremony creates the organization through the same invariant on a 17.x store that holds none (admitted as in (a)) and attributes rows by raw SQL, never through the engine.
  • Simulation verified: an id-less payload is simulated under INSERTED_ORGANIZATION_ID, which no grant names; the slug read appends the inserted row and re-filters on slug and non-empty id; the page read keeps limit + ids.size and appends the inserted row, so sole-plus-inserted reads as several; applyPending leaves stored rows alone (ids empty). Fail-closed is kept: the scan ceiling and read failures refuse through failClosed. The dev's ablations (enforceStanding removed, wall short-circuit removed, inserted row dropped) each red the pins that would catch them.

2. One Default Organization definition — right; all seven former copies compute the same organization.
findDefaultOrganization(read): { slug: 'default' } limit 1, else {} limit 2 and sole, idOf = non-empty string of any id, several reported. Checked copy by copy against the removed code:

  • resolver readDefaultOrganizationId: same idOf, same two reads through tryFind; kept the registryHoldsObject short-circuit and the loud AuthzStoreUnavailableError. Same answer; the memo and grantAnchorsPlatformStanding are untouched.
  • default-organization-invariant.ts: same reads, propagating; ambiguous.organizationCount was existing.length under a limit of 2, so it was already always 2 — now the literal, with the reason beside it. Same answer; several replaces the former more-than-one length test exactly.
  • ensure-default-organization.ts: findDefaultOrganizationId over swallowing tryFind; the walled branch still reads only slug = 'default', and lines 541–549 say why (under a wall an organization is a tenant's boundary, never "the" organization) — justified by ADR-0131 D5 (no grant row under a wall) and D12, unchanged by this PR.
  • last-admin-guard.ts: the guard's own read with the pending simulation and scan; before, .find(slug === 'default' && toId(id)) then a page of 2 + ids.size with length === 1; now the same through the callback. Kept on purpose and rightly: the resolver's read cannot simulate a pending write and would answer where the guard must refuse.
  • tenancy-service.ts resolveDefaultOrgId: over findRows (a thrown read is []), null for none. Same answer.
  • suggested-audience-bindings.ts resolveDefaultOrganizationId: over scopedSystemCtx() with fields: ['id'], a thrown read propagates. Declared edge: a numeric id was read as none and is now read as its string — aligned to the resolver, which the promotion (bootstrap-platform-admin.ts, which calls this reader) must agree with so the row it writes is the row the resolver reads. Right.
  • organization-ownership-plan.ts: raw SQL; a store with no slug column answers the slug read []. Declared edge: a default-slugged row with a blank id used to name nothing and now falls through to the sole reading — the resolver's reading, which D10 fate 3 must match for the certified database to boot. Right.
    The core test pins the rule (slug wins without a second read; sole; several; none; blank id; propagation) and the resolver's read over it (registry absent is not read).

3. The ceremony pin — right, as carrier 2 asked. grant_promoted (NULL organization_id, by id to a NULL-owned admin_full_access mirror — the 17.x shape) is in the acceptance fixture; the test reads readDefaultOrganizationId from @objectstack/core over the same SQLite database (a one-argument find double; registryHoldsObject reads no registry as held) before the ceremony and pins the attributed owner equal to that answer (org_default, the default-slugged row beside org_b), and NULL under a wall. The fixture consequences are right: the catalog double declares admin_full_access as the composed security plugin ships it; the "uncomposed security plugin" block opts out, which is its premise; the walled sys_permission_set count 3 → 4 is the extra mirror a wall does not delete. The dev's ablation note is honest (a plan naming org_b reds the file at the effective-access assertion before the new pin); the pin still binds the ceremony to the resolver's answer.

4. Published surface of @objectstack/core. New on the root index (via export * from './security/index.js'): readDefaultOrganizationId (was private), findDefaultOrganization, DEFAULT_ORGANIZATION_SLUG, and the types DefaultOrganizationRead and DefaultOrganizationAnswer. Additive, named right, with the read-policy contract in the docblocks. Callers on the head: plugin-auth (invariant, bootstrap, tenancy, guard), plugin-security (bindings), cli (plan; apply test). plugin-auth, plugin-security and cli publish no new symbol.

5. The dev's residual — default-slug capture under single. Reach, read in code: at insert time a new organization owns no grant, so capture needs a later admin_full_access grant owned by it (a platform-level writer's act) or an existing organization re-slugged to default (the update half's domain, pre-existing). Doors to the insert under single: better-auth POST /auth/organization/create answers 403 ORGANIZATION_CREATION_DISABLED (auth-manager.ts 3712–3720, multiOrgPostureEffective()); the data API declares apiMethods: ['get', 'list', 'update'] on sys_organization, so create is 405, and the import the 405 body advertises is gated precisely by write mode (data-action-routes.ts 388/498 → api-derivation.ts 364–368): insert-mode and upsert-mode need create and are 405 before the engine; only update-mode import is admitted, which is an engine update. So the half guards writers below HTTP — seeds, scripts, a host's engine calls — as the PR says. Severity: low; the admitted capture is bounded by needing a platform-level writer, and the lockout and the disarmed guard are closed. The open door is the update half's slug (③).

6. R1–R4. R1 (a fresh install boots and serves under single): the dev measured a production os serve of examples/app-crm on a fresh SQLite (health, sign-up, sign-in, session 200) and the harness's fresh-store leg passed in CI. R2 (HEAD refuses the 18.0 fixture until the ceremony, then boots it) and R3 (a v17-shaped store is refused, takes the ceremony, boots): the labelled harness lane concluded success on this head (run 39826393590). R4 (walled postures boot and serve with no organization-less grant): the dev measured isolated and group, two organization creates each through the hook's wall path, 0 organization-less grants. Nothing in the diff touches a boot refusal, the ceremony's step order or DDL, so the CI legs are the right evidence to rely on.

② Semver level

  • Clause-②: yes (narrowing) on the PR body and the changeset: right — the engine's sys_organization insert accept-set narrows under single. No widening arm is owed by the core exports: clause ② is the payload accept-set, and the level reads the kind.
  • Metadata-protocol: none: right. No authorable key is added, removed, tombstoned or re-meant; the change is a runtime refusal plus TS exports (the kind table in pr-automation.yml and AGENTS.md Post-Task step 3 on main: non-authorable internal types, services, fix!: alike → patch).
  • Levels patch on core, plugin-auth, plugin-security, cli: right under maintainer ruling A of 2026-10-21 (changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105): the level follows the kind alone, none takes patch on every entry (a minor would be refused as none-minor by check-changeset-no-major's level axis), and a non-protocol breaking change ships patch and keeps its carriers. Record 6253205661's "a minor widening of core, declared then" predates ruling A and is superseded by it; .changeset/24883-last-admin-guard-default-organization-anchor.md (a narrowing at patch, Metadata-protocol: none) is the landed precedent.
  • Carriers: ! on the title; the BREAKING banner names the exact population (a single deployment whose only organization is not slugged default and whose last administrator holds only its grant), FROM/TO per item, and the operator action. The ADR-0087 marker is exactly one, adr-0087: not-required (no-migration-prescription) with its why — a category in CATEGORIES, honest because no metadata key moves, objectstack migrate meta has nothing to rewrite and no tombstone is owed; the body's operator action is a data remedy, not a consumer code rewrite, so the prescription detector has nothing to refuse. The real gate ran on this head (Check Changeset runs check-adr-0087-registration.mjs --base after its self-test) and concluded success, as did the level axis.
  • check-governed-merges: 0 of 13 paths governed; not Tier H or S — consistent with the file list (no docs/adr/**, no spec schema).

③ Boundary flags

  • os-dev-report: premise_still_valid: true; open_questions: []; three out-of-scope findings, three deviations, one not-measured note — each answered below.
  • Finding 1 (class a: default-slug anchor capture admitted when another administrator stands): judged in ① item 5 — bounded at the insert. The real capture door is a RE-SLUG of an existing organization to default through the update half: better-auth organization/update accepts slug (sys-organization-update-door.test.ts line 83), its beforeUpdateOrganization (auth-manager.ts 3859) checks only the environment binding, and the single gate at 3712 is on create alone; the data door's user-context PATCH of better-auth's own columns is refused by ADR-0092 D2 per the object's note. On a multi-organization single store with no default slug — the shape the boot already warns about — an organization owner whose organization holds a platform-granted admin_full_access can become platform administrator by re-slugging it. Pre-existing, outside this card's surface, measured by nobody. ESCALATE: a card under feat(spec,drivers,objectql,plugin-security): organization_id NOT NULL per cleared table; one predicate for Layer 0 and every driver; both orWhereNull arms, the __global__ sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212's reconciliation to measure the better-auth organization/update slug door under single and decide whether the update half refuses a re-slug TO default beside other organizations, or whether multiOrgPostureEffective() gates the slug too. Not a FAIL here: this PR does not open it.
  • Finding 2 (the anchor predicate spelled twice — grantAnchorsPlatformStanding in core, anchorOwnerTest in the guard): accurate, and the dev's reason not to fold them holds (the guard's form reads the simulated Default Organization). It is the half of 6253205661 carrier 3 that claim 6345294692 left out. ESCALATE: record it in the epic's reconciliation ledger so it is not lost a second time; exporting grantAnchorsPlatformStanding is its own card (a none-kind patch widening of core).
  • Finding 3 (ambiguous.organizationCount is always 2; auth-plugin.ts 890, identity-objects-plugin.ts 132 and cli/src/commands/migrate/organization-ownership.ts 1010 print "holds 2 organizations"): pre-existing, now explicit in code. Accept as cosmetic; a follow-up may print "several" instead. No action owed here.
  • "Not measured: whether import on sys_organization reaches beforeInsert": answered by reading in ① item 5 — insert-mode and upsert-mode imports are refused 405 before the engine by the precise write-mode gate; no insert reaches the hook from that door; update-mode import is the update half's.
  • Design question, not escalated as a defect: the insert half admits the move whenever any administrator (an organization owner included) remains, so platform standing can still drop silently in the owner-bound shape. That is ADR-0135 D5.2's invariant as the update and delete halves already apply it; a standing-preservation rule for the D5 anchor would be a new decision for the epic seat, not this card.
  • Deviation 1 (worktree moved to ../objectstack-issue-25208 after the main-checkout hook refused writes): process only; branch and remote are the claim's. Deviation 2 (trailer pair in the AGENTS.md form, not the harness-suggested model-named one): AGENTS.md wins, as the harness reminder itself says. Deviation 3 (catalog double declares admin_full_access by default; the uncomposed block opts out; walled count 3 → 4): judged right in ① item 3.
  • Claim scope held: no docs/adr/**, no ORGANIZATION_OWNERSHIP_SHAPE_GENERATION change, the localization fallback in resolve-authz-context.ts untouched (the diff there is one function and one import).
  • The two gates the dev could not run locally (check-plugin-teardown-shape --self-test, check:dual-build-cjs-loads) are covered by Lint & Repo Gates and Build Core on the head, both success. The PR is still a draft; this record does not change that.

Implemented-by: claude/issue-25208-8c2-carriers
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-22T18:51Z. PR #25265 at 6f6e90033c775667aeb3a6bb20fa88d74f1cfd08 (#25208; claim 6345294692; os-dev-report 6348218751). ACCEPT.

Seat check

  • Shape: 13 files, +516 / −117. check-governed-merges --pr 25265: 0 of 13 paths governed, so this is an ordinary queue landing.
  • What it does: the three 8c-2 review carriers (6253205661).
    • Carrier 1, a new sys_organization beforeInsert guard. It refuses an insert under single that would leave no administrator who can sign in. The dev measured that a second organization beside a sole non-default one can end the admin's standing. The guard uses the same enumerate–simulate–enumerate rule as the update and delete halves (ADR-0135 D5.2), and does no reads under a wall.
    • Carrier 2, the ceremony pin. A NULL admin_full_access grant is attributed to readDefaultOrganizationId's answer.
    • Carrier 3, one Default Organization definition exported from core (findDefaultOrganization, DEFAULT_ORGANIZATION_SLUG, readDefaultOrganizationId). The seven former copies compute the same organization and keep only their own read policy.
  • Review: CONTRACT_REVIEW_TIER, narrowing face, from an isolated reviewer: PASS at this head (the record above).
    • Every legitimate insert is admitted, judged by reading the code: the boot invariant, ensureDefaultOrganization, seed replay, a walled create, the ceremony.
    • The changeset is right: all patch, Clause-②: yes (narrowing), Metadata-protocol: none, BREAKING, and ADR-0087 not-required (no-migration-prescription).
  • CI: the latest run of every check is success or skipped. The labelled 18.x upgrade harness lane passed (run 39826393590); its skipped twin is the pre-label run.
  • R1–R4:
    • R2 and R3 are the harness lane.
    • R1 and R4 were measured by the dev on this head: a production os serve under single, isolated and group each answered 200; two walled organization creates each passed through the guard's wall path; 0 organization-less grants.
    • Full dogfood passed 2481/2481.
  • Escalations, and where each goes:
  • Landing: the seat flips the PR ready and enables auto-merge. Fixes #25208 closes the card.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 18:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 8dfd126 Oct 22, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25208-8c2-carriers branch October 22, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants