Skip to content

fix(plugin-auth): the last-administrator guard answers a read fault 503 SERVICE_UNAVAILABLE, the code its sibling guards answer with - #25388

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ef9bkc
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ef9bkc

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #25364

Clause-②: no
Metadata-protocol: none

Measured first: the shared shape, not one half

The card asked whether to change one half of the last-administrator guard or the shared failClosed shape. Measured on main at d12e650:

  • Every half that judges a write (enforce for the two sys_user halves, enforceStanding for the membership, grant, activation-ledger and organization halves, and judgeOrganizationDelete) runs inside failClosed, which answered a read fault with refuse(): 403 PERMISSION_DENIED.
  • The Default Organization slug-capture rule (guardOrganizationDefaultSlug, from fix(plugin-auth)!: under single, no slug change makes another organization the Default Organization #25286) does not use failClosed. It has its own catch, which also answered 403 PERMISSION_DENIED.
  • So changing the slug rule alone would have split the guard: a read fault on the slug-capture rule would answer 503 while the same fault on the update half of the same organization/update request answered 403. Both now throw one shape.

What changes

  • last-admin-guard.ts: a new module-private readFault() beside refuse(): code: 'SERVICE_UNAVAILABLE' (typed StandardErrorCode, an existing ADR-0112 member, no new code), status: 503, object = the table the caller was writing, cause = the read's own failure. failClosed and the slug-capture rule's catch both throw it, with their messages unchanged. Both now pass an inner read fault through unchanged as well as an inner refusal (judgeOrganizationDelete nests one failClosed inside another), so the fault is never wrapped twice.
  • Refusals on the merits stay 403 PERMISSION_DENIED, byte for byte. That includes the maxScan overflow: that read completed and proved too large, so it is a refusal with a remedy, not a read fault.
  • objectql-adapter.ts (the better-auth door): rethrowAsBetterAuthError gains an arm for an engine error that declares code: 'SERVICE_UNAVAILABLE' and status: 503, answering APIError('SERVICE_UNAVAILABLE') with that code and a fixed message (the driver's text is not relayed, as mapDataError's declared-5xx relay withholds it). Without this arm the 503 reached better-call's router as an unhandled fault: measured by reverting only this file, organization/update answered 500 with no code. The arm is keyed on the declared pair, not on the guard, so any engine error declaring that pair gets the answer /data already gives it.
  • auth-manager-actor-guards.ts (organization/delete): the Default Organization's standing judge mapped only PERMISSION_DENIED. It now maps the guard's read fault to APIError('SERVICE_UNAVAILABLE') with ORGANIZATION_DELETE_READ_FAULT_CODE, the code that admission's own read faults (fix(plugin-auth): an organization-delete door lookup that throws hands the delete to better-auth uncounted, so a driver without transactions keeps a partial delete #25059) already answer with, and a message that does not relay the driver's text.

The expected landing was last-admin-guard.ts. The two other files are in the same package (@objectstack/plugin-auth) and are the doors the guard's refusal crosses: without them, the code moves at the engine and the /data door but the better-auth doors answer 500.

Security floor

No change turns a refusal into an allow. Every changed site still throws, from the same place, before any write. Only the code and status of a fail-closed refusal move (403 to 503). The pins below assert that nothing was written in each fault case (bannedFlag, userExists, memberRole, slugOf, and no attempted write on organization/delete).

Pins (all assert code + status)

  • HTTP door, organization/update (single-reslug-default-anchor.test.ts): a failed read of the Default Organization now answers { status: 503, code: 'SERVICE_UNAVAILABLE' } (was 403 PERMISSION_DENIED), the body does not contain the injected driver text, and the slug is unchanged. That is the code auth-manager.ts's organization slug guard answers its own read fault with (auth-manager.org-slug-guard-system-context.test.ts).
  • Engine door, system-context update: the same fault gives { code: 'SERVICE_UNAVAILABLE', status: 503 }.
  • Flipped in last-admin-guard.test.ts: the ban, user-delete and role-downgrade fail-closed cases now assert { code: 'SERVICE_UNAVAILABLE', status: 503 }. Their message assertions (the reason, "could not be verified") are unchanged.
  • New: the maxScan overflow keeps { code: 'PERMISSION_DENIED', status: 403 }; judgeOrganizationDelete with a failed inner read answers the read fault once (one SERVICE_UNAVAILABLE: prefix); and organization/delete of the Default Organization through the real guard answers 503 SERVICE_UNAVAILABLE with nothing attempted. Its CONTROL, with the read back, is the guard's verdict on the same door: 403 PERMISSION_DENIED.
  • Repo sweep for pins on the old shape: git grep for the guard's fail-closed prose and for tests driving the guard through a failing read found pins only in plugin-auth: the 5 flipped above. No doc or ADR states the read-fault code.

Verification

  • Reverse check, run against main's three source files (git restore --source=d12e650 on those files only, then restored from HEAD with git diff HEAD empty): 7 failed / 137 passed across the four touched test files, every new or flipped pin red. Adapter alone reverted: the HTTP pin reads expected { status: 500, code: null }.
  • @objectstack/plugin-auth full suite at 3aee198: 187/187 files, 3528 passed, 10 skipped. pnpm --filter @objectstack/plugin-auth typecheck (src, examples, test-typecheck): exit 0.
  • Derived gate families (dispatch-gates.mjs --commands, reconciled with --ran): 69 derived, 67 run with exit 0, 0 unrun. Two came back NOT MEASURED locally and are left to CI: check-plugin-teardown-shape --self-test needs history this shallow clone does not have, and check:dual-build-cjs-loads needs a full monorepo build.

Acceptance notes

  • The adapter arm also gives every other engine error that declares { status: 503, code: 'SERVICE_UNAVAILABLE' } the 503 answer at the better-auth door, where it used to be an empty 500. That is the same answer mapDataError gives on /data. The other in-repo producers of that pair found by git grep (objectql/src/action-activation.ts, service-automation/src/engine.ts) are action and flow paths, not adapter writes; this was read, not measured.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CuJfNjvts78GKesupEdgbz

…03 SERVICE_UNAVAILABLE

Claude-Session: https://claude.ai/code/session_01CuJfNjvts78GKesupEdgbz
Co-authored-by: Claude <noreply@anthropic.com>
…doors; changeset

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/api/error-catalog.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/api/index.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/permissions/authorization.mdx (via registerLastAdminGuard (symbol, a top-level function), SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/permissions/sharing-rules.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/protocol/kernel/error-handling.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/ui/print-pages.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))

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

  • content/docs/releases/v17/17-0.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/releases/v17/17-3.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/releases/v17/17-4.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))
  • content/docs/releases/v17/17-7.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in LAST_ADMIN_GUARD_READ_FAULT_CODE; a string literal in assertOrganizationDeletable; a string literal in isEngineReadFault; a string literal in rethrowAsBetterAuthError))

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 42 pages)
  • 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 — 19 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 c898bd8ac3f6981c9f45b16dabedea4459095bae → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c898bd8ac3f6981c9f45b16dabedea4459095bae

⚠️ 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 c898bd8ac3f6981c9f45b16dabedea4459095bae → 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

席位核对

ACCEPT · domain:engine seat 1 · os-tesla · session_014Hse9cj2i4o5UAVrd21NKr · 2026-10-23T05:59Z · head 3aee19812 · #25364 (Fixes)

  • Shape: 8 files, +209 / −24, all in plugin-auth: last-admin-guard.ts (a module-private readFault()), objectql-adapter.ts (a 503 arm), auth-manager-actor-guards.ts (the org-delete judge's read fault), four pin files and a patch changeset. Read from the diff. NOT GOVERNED, under the 3000-line threshold.
  • Clause-② no: it reuses the existing SERVICE_UNAVAILABLE (ORGANIZATION_DELETE_READ_FAULT_CODE is that same code on main, organization-delete.ts:213), and no new code is introduced.
  • Security floor, read on the diff: a read fault still throws, so the guarded write never proceeds. Only the status and code change, from 403 PERMISSION_DENIED to 503 SERVICE_UNAVAILABLE. Merits refusals, including the maxScan overflow, stay 403. A nested judge re-throws an inner read fault unchanged (isReadFault), so it answers once. No refusal becomes an allow. The adapter's message is generic and carries the engine error only as cause, so nothing new is disclosed.
  • Cross-lane: plugin-auth was declared on [PM seat] domain:services — 🟢 os-sales #6021 (6358219939), including the adapter widening. No objection was posted.
  • Gates: 41 check-runs on 3aee19812: 34 success, 7 skipped (opt-in or path-filtered), 0 failed. The dev's two NOT-MEASURED gates are covered by CI, green.
  • Pins: the HTTP door organization/update and the engine door flip to {503, SERVICE_UNAVAILABLE}. Three fail-closed pins in last-admin-guard.test.ts flip. New pins cover the overflow staying 403, a nested judge answering once, and organization/delete answering 503, with a 403 verdict as control. The reverse check on main's sources gives 7 red.
  • Accepted deviation: the landing grew beyond last-admin-guard.ts. Without the adapter arm, organization/update answered an empty 500, measured by reverting that file alone. The arm keys on the declared pair (code plus status 503), so ANY engine 503 SERVICE_UNAVAILABLE at the better-auth door now answers 503 instead of an empty 500. That is a strict improvement and is declared on the services seat post.
  • Landing: the seat marks it ready and enables auto-merge; the merge queue lands it.

Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants