Skip to content

fix(plugin-auth): an organization-delete read that fails refuses 503 before better-auth's first write - #25184

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25059-org-delete-lookup-refuses
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25059-org-delete-lookup-refuses

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #25059

Clause-②: no
Metadata-protocol: none

Why Clause-②: no: the refusal replaces an outcome only a failed store read produces, and the typed 503 replaces a bare 500 fault; no request a caller can make is answered differently.

What changed

AuthManager.assertOrganizationDeletable, the POST /organization/delete admission in better-auth's global hooks.before:

  • A failed session read refuses as well. resolveActor answered a failed session read with null, the value "no session" gives, so the admission fell through. It takes an opt-in failedReadThrows option now. With it, when no actor was resolved and the explicit token read itself threw, that throw propagates, and the admission answers it with the same typed 503. No credential, a credential the store does not hold, an expired session, a credential that does not parse, and one carrying a control character all still answer null, so they reach better-auth's 401. Only the admission passes the option. The other nine this.resolveActor(ctx) call sites are byte-identical in the diff.
  • A read it decides on that fails now refuses. A failed read of the caller's sys_member row or of the sys_organization row used to land in catch { return; } and hand the request to better-auth, which re-reads, then clears the session's active organization, then deletes the members and invitations, and only then meets the engine's refusal at the organization row. It now answers 503 with the standard ADR-0112 code SERVICE_UNAVAILABLE, before better-auth's first write.
  • A failed Default Organization decision answers the same typed 503. It already refused before any write, but with a bare Error, which better-auth serves as an untyped 500.
  • The vendor-decided cases still fall through, unchanged: no actor, no member row, not permitted, no organization row.
  • The wire pieces sit beside the 409's in organization-delete.ts: ORGANIZATION_DELETE_READ_FAULT_CODE and organizationDeleteReadFaultBody(object). The body names the table it could not read. The driver's own error rides cause and is not relayed. Two names were added to the existing organization-delete.js import list in auth-manager.ts; nothing else outside the method moved.

The four mechanism assumptions, re-measured on this branch

  1. The lookup block. Measured: it holds four lookups, but resolveActor cannot throw. Both of its paths catch, and it answers null, the same value as "no session". So of the four, three can throw into the catch: the sys_member read, hasPermission and the sys_organization read.
  2. The Default Organization decision answers 500. Measured by ablation A2 below: with this change reverted to main's bare propagation, the pin reads expected 500 to be 503.
  3. Which status and code. 503 SERVICE_UNAVAILABLE: the vocabulary this file already uses for a read fault (the organization slug guard, the invitation role cap and the invitation placement check), a StandardErrorCode, so the error-code ledger needs no row. check:dispatcher-error-vocabulary ran green (Verification below).
  4. Can hasPermission throw for a decidable vendor answer? Yes, and only for that. better-auth 1.7.12 plugins/organization/has-permission.mjs reaches the adapter only behind options.dynamicAccessControl?.enabled, and the admission hands it the roles map alone, so the call reads nothing. It throws only on the member row itself: a role that is not a string (role.split), or a role name that resolves to an inherited object member (authorize is not a function). better-auth's deleteOrganization calls the identical predicate (the same roles object: organizationOptions.roles is the customOrgRoles the admission holds as orgRolesMap) on the same row, before setActiveOrganization. So that throw keeps falling through, and a pin holds that nothing is attempted when it does.

The session read

The card names resolveActor among the lookups that throw. Measured: it never threw. Both of its paths catch and answer null, so a failed session read read as "no actor" and fell through. An uncommitted scratch test failed only the admission's own session reads while better-auth's stayed real; it got 200, and better-auth's session update and member and organization deletes ran with nothing counted.

resolveActor(ctx, { failedReadThrows: true }) now tells the two apart. It is used by the admission alone, and the default reading is unchanged for the other nine callers.

Only the explicit token read counts as a failed read. Two things are deliberately not counted:

  • the first path's throw, when the second path finds no token;
  • a failure reading a token that carries a control character. PostgreSQL refuses U+0000 in a text parameter, and a crafted cookie can carry %00. Counting it would turn that request's 401 into a 503.

Pins

packages/plugins/plugin-auth/src/organization-delete-read-fault.test.ts. Real ObjectQL over @objectstack/driver-sql and better-sqlite3, the manifest's objects, a real AuthManager with the organization plugin (the session-read-failure-answer.test.ts harness).

  • One case per read that can fail: the caller's session read (both of resolveActor's paths fail inside the admission's call, fired 2), the caller's sys_member read, the sys_organization read, and the Default Organization decision's count. The admission's own read fails once, scoped to the admission's frame, so better-auth's reads of the same table stay real. Each case asserts 503, SERVICE_UNAVAILABLE and the table named. It also asserts that no write was attempted: the session update and the member, invitation and organization deletes are recorded by engine hooks when attempted, not when committed. Then a CONTROL: with the read back, the same delete answers 200 and the organization is gone.
  • What still falls through: a request with no session gets better-auth's 401, with nothing attempted. A credential carrying a control character gets the same 401, with nothing attempted, even while the admission's read of it fails.
  • The hasPermission fall-through: a stored member row whose role is null reaches better-auth, which refuses before its first write. Nothing is attempted, and the answer is not the admission's 503.

Ablations

A1 to A3 ran at 947ab6c25 (before the merge of main); A4 and A5 at 5884148db, the final head (baseline there Tests 7 passed (7)). The test imports ./auth-manager.js relatively, so the subject resolves to src/ and no build sits between a mutation and its run. Each mutation was applied with scripts/ablation-replace.mjs (the anchor hit once, landed on disk, blob changed), and then restored.

mutation result
baseline none Tests 4 passed (4)
A1 the read helper swallows the fault (main's fall-through put back) the three read pins red, each expected 200 to be 503: the request reached better-auth and deleted the organization
A2 the Default Organization decision propagates bare (main's R3) its pin red, expected 500 to be 503; the other three green
A3 a hasPermission throw refuses 503 instead of falling through the fall-through pin red, expected '{"code":"SERVICE_UNAVAILABLE",…' not to contain 'SERVICE_UNAVAILABLE'
A4 resolveActor swallows the failed session read again the session-read pin red, expected 200 to be 503: the request reached better-auth and deleted the organization
A5 the control-character guard removed the control-character pin red, expected 503 to be 401

Restore after each mutation: blob f3015ed6f7b0 == HEAD, git diff HEAD empty, zero markers on disk, git status --porcelain empty.

Verification

  • At the final head 5884148db, under the lock: pnpm --filter @objectstack/plugin-auth build exit 0; pnpm --filter @objectstack/plugin-auth exec vitest run gave Test Files 185 passed (185), Tests 3485 passed | 10 skipped (3495); pnpm --filter @objectstack/plugin-auth typecheck exit 0 (check:test-typecheck: OK, no debt row added); pnpm check:type-check-debt exit 0 (re-measure: OK — 1 ledger entr(ies) re-measured in 190.9s, 26 raw tsc error(s) total, none above its recorded number). The lock printed VERDICT command-exit 0.
  • Gate union at 5884148db: the 70 commands derived by node scripts/pm/dispatch-gates.mjs --commands (the same 70 at 947ab6c25, 46c61dade and 5884148db), plus check-changeset-fixed and check:authz-resolver, whose rosters sit in a directory this diff touches. Every exit code was captured before any pipe, and all are 0. --ran: 70 derived · 70 run · 0 NOT-MEASURED · 0 UNRUN.
  • The other nine callers: in the diff, the one changed resolveActor( line is the admission's; git grep counts nine this.resolveActor(ctx) call sites, and failedReadThrows: true occurs once (auth-manager.ts:8378, inside assertOrganizationDeletable).
  • Not run locally: the full-farm check:* sweep, pnpm lint, the dogfood battery (the title carries no ! and there is no (narrowing) arm), and the packages/qa dogfood pins. CI runs those; every check-run on 5884148db succeeded or was skipped.

Acceptance notes

  • The [Decision] Deleting an organization once organization_id is NOT NULL: refuse while it still owns records, or delete them with it #24948 dogfood pin for a failed Default Organization lookup (packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282) asserts a status of 400 or above. It stays green with 503. It could be tightened to 503 plus SERVICE_UNAVAILABLE. That file is in the domain:cli lane and was not edited here.
  • The record count (organizationDeleteRefusal / defaultOrganizationDeleteRefusal) still propagates a failure bare, as an untyped 500. That is the same family as the Default Organization decision. It is not changed here because what reaches it is not only a store fault: an engine refusal carrying its own code lands there too, so a blanket 503 would mislabel it.
  • A client sees 503 SERVICE_UNAVAILABLE from an organization delete only when a store read failed. Nothing was deleted then, and a retry is the remedy.

Generated by Claude Code

…before better-auth's first write

The organization-delete admission caught every throw in its lookup block and
handed the request to better-auth, so a failed sys_member or sys_organization
read let better-auth clear the active organization and delete the members and
invitations with nothing counted. A failed read now refuses with a typed
503 SERVICE_UNAVAILABLE, and so does a failed Default Organization decision,
which answered a bare 500. The vendor-decided cases still fall through.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
… read, with controls

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…g-delete-lookup-refuses

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…eneath the engine

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
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 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

24 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 f7d740376d3953c7b4a7d22c77e0f689c77a228a.

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

What this run could not see
  • 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 — 18 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 f7d740376d3953c7b4a7d22c77e0f689c77a228a → packageMentionDocs.

Which tree this was computed on

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

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

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

…session read too

resolveActor answered a failed session read with null, the value no session
gives, so the admission fell through and better-auth deleted with nothing
counted. An opt-in failedReadThrows mode, used only by the admission, lets the
explicit token read's failure propagate, which the admission answers with the
same typed 503. No session, an unknown or expired credential, one that does
not parse and one carrying a control character still answer null; the other
nine callers are unchanged. The changeset's Clause-② line now carries no
reasoning.

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

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T11:17Z · ACCEPT at head 5884148db · dev reports on #25059 (round 1, then the patch round 6337353275)

  1. Shape: a draft PR to main whose first line is now Fixes #25059, assigned os-bill. A bare Clause-②: no and Metadata-protocol: none are at line start in the body and the changeset, with the reasoning on its own line. The file surface is claim 6333154298, amended in 6335204641 (resolveActor's opt-in mode).
  2. The card's contract is met in full: every read the organization-delete door decides on, now including the caller's session read, refuses with a typed 503 SERVICE_UNAVAILABLE before better-auth's first write when it fails.
    • The decidable cases still fall through to the vendor: no session, no member row, not permitted, no organization row.
    • Seat answers 6335204641: Q1 A, an opt-in failedReadThrows used by the admission alone; Q2 A, a hasPermission throw falls through.
    • The other nine resolveActor(ctx) call sites are byte-identical.
    • A credential carrying a control character still reaches the vendor's 401, so a crafted cookie cannot turn a 401 into a 503.
  3. Gates (check-runs pinned to this head, the newest run of each name):
    • 32 success, including Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset and Test Core 1–6.
    • 9 skipped, each on the check-expected-skips.mjs roster (Auto Label and Check PR Size on the seat's body-edit run).
    • mergeable_state: clean. check-governed-merges.mjs --pr 25184: NOT governed, +445 / −31.
    • Locally: plugin-auth 185 files / 3485 tests and typecheck pass; 70 of 70 derived gates plus two roster gates exit 0.
    • Ablations A1 to A5 each turn their pin red, and each restore is clean.
    • No contract face is hit: a refusal that replaces an outcome only a failed store read produces, with no caller-made request answered differently. So the seat's read and CI decide it.
  4. Out of scope (in the PR's Acceptance notes):
    • the record count's failure still propagates as an untyped 500, left on purpose because an engine refusal with its own code lands there too;
    • the packages/qa dogfood pin could be tightened to 503 (domain:cli, carrier none).

Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #25059 closes by Fixes; the seat removes pm:dispatched and records the landing.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 11:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 11:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit e5f194b Oct 22, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25059-org-delete-lookup-refuses branch October 22, 2026 11:47
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