You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Filing gate ③: a finding from the #24948 fix (PR #25016). The seat's contract review recorded it as S1 and S2 at 139abc45a (6308964493). Neither blocks that PR.
Reader who acts: the plugin-auth lane (auth-manager.ts, assertOrganizationDeletable).
Dedupe: none found ("organization delete fall-through vendor uncounted", "assertOrganizationDeletable throws").
What the review found
S1. A thrown lookup falls through uncounted. The organization-delete door, assertOrganizationDeletable, runs in better-auth's global hooks.before. Its first block is a try (auth-manager.ts:8127-8158 at 139abc45a). That block hands the request to better-auth, with no count and no refusal, in two kinds of case:
when it decides the vendor answers the request: no actor, no member row, the vendor would refuse anyway, or no organization row;
and also when one of those lookups throws: resolveActor, the caller's sys_member read, hasPermission, or the sys_organization read.
On the second kind, better-auth's writes run: its session write, then the member and invitation deletes. The engine's refusal at the organization row still answers inside the request's unit. On a driver that holds the unit, nothing is lost. On a driver without transactions (Turso remote), the members and invitations stay deleted while the organization row survives. That is the partial delete #24948 ruled out.
The trigger is infrastructure-only: a caller cannot make the lookup throw. The door documents the fall-through as its design ("everything this cannot decide falls through to the vendor"). PR #25016 already closed the same gap for the Default Organization decision (its R3: isDefaultOrganizationTarget now runs outside the try, so a failed lookup refuses).
S2. That refusal is a 500, not a 4xx. The R3 refusal is a plain Error thrown from hooks.before, not an APIError, so the wire answer is a 500-class fault. It refuses and rolls back, which is the contract. The dogfood pin asserts only status 400 or above.
Fix, with pins
In the try, let a thrown lookup refuse, as R3 does, and keep the fall-through only for the decidable "the vendor answers this" cases.
Answer both refusals with a typed APIError (for example SERVICE_UNAVAILABLE), not a bare Error.
Claim: PM loop round 1 · 2026-10-22T05:02Z · p3, domain:services, area:access
Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-25059-org-delete-lookup-refuses
Worktree: objectstack-issue-25059
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface (at origin/mainb93c750c7):
packages/plugins/plugin-auth/src/auth-manager.ts, assertOrganizationDeletable only (about :8326–:8395; its hooks.before call at about :2470 is read, not moved).
packages/plugins/plugin-auth/src/organization-delete.ts, only if the typed refusal for a failed Default Organization decision belongs beside isDefaultOrganizationTarget.
Tests under packages/plugins/plugin-auth/src/; .changeset/25059-*.md.
⛔ Not packages/qa/** (domain:cli): the R3 dogfood pin there asserts a status of 400 or above, which a typed refusal still meets, so it needs no edit. Not packages/objectql/** (the engine's refusal at the organization row is unchanged). Not packages/spec/**. Not assertRemoveMemberPermitted, whose fail-open is its own documented contract.
Stop on a breach and explain it in the report.
Container & model: S, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")
Clause-②: no
Responsibility: the organization-delete door hands a request whose lookup threw to the vendor uncounted, and answers a failed Default Organization decision as a 500 | #24948's contract as PR #25016 shipped it: the delete is refused while the organization owns records, decided before the vendor's first write, on every driver | an organization owner deleting on a driver without transactions (Turso remote) while one lookup fails; found by #25016's contract review (S1, S2 in 6308964493)
Thread-read: none
Serial constraints cleared:
No auth-stack version-family upgrade is in flight (pnpm-workspace.yaml overrides are untouched by open PRs).
The declaration reads the card: the refusal replaces an outcome only an infrastructure fault produces, which contradicts #24948's contract, and the typed status replaces a 500 fault. The dev measures it, and a measured move to the narrowing arm stops the build and comes back in the report.
os-dev-report
{
"issue": 25059,
"status": "done",
"branch": "claude/issue-25059-org-delete-lookup-refuses",
"pr": "#25184",
"session": "session_01UnHcTADjeg3VwxF21QHj7W (subagent run of the dispatching seat; the harness-stamped Claude-Session id)",
"premise_still_valid": true,
"summary": "assertOrganizationDeletable now refuses with a typed 503 SERVICE_UNAVAILABLE (APIError, the standard code this file already uses for read faults) before better-auth's first write when its sys_member read, its sys_organization read or the Default Organization decision's count fails; the vendor-decided cases (no actor, no member row, not permitted, no organization row) still fall through, and the code plus body builder sit beside the 409's in organization-delete.ts. Measured before deciding: resolveActor cannot throw (both paths catch and answer null, the value no-session also gives), so a failed session read still falls through; a scratch test (not committed) that failed only the admission's session reads got 200 with better-auth's session update and member/organization deletes run uncounted. Closing that needs resolveActor (10 call sites) to tell a failed read from an absent session, outside the claimed surface, so the PR first line is Part of #25059, not Fixes (the dispatch said Fixes; the definition's Part-of rule wins while a named half stays open) and the fork is open_questions[0]. hasPermission was measured I/O-free here (handed only the roles map, no dynamicAccessControl), throwing only on the member row itself (a non-string role, or a role naming an inherited object member), which better-auth's identical call on the same row also throws on before setActiveOrganization, so that throw keeps falling through, pinned as nothing-attempted. Stop condition not met: every new refusal needs a failed store read; no caller-made request changes answer. Base was 504d8b3 (origin/main moved off b93c750 before the worktree existed; the delta was objectql only), and origin/main 82cfaf7 was merged before the PR opened.",
"tests": "All at the lock, VERDICT lines read. (1) final head 46c61da: vitest run src/organization-delete-read-fault.test.ts src/organization-delete.test.ts, Test Files 2 passed (2), Tests 18 passed (18), VERDICT command-exit 0. (2) 5db01d5 (after merging main 82cfaf7; the last commit only edits a test comment): pnpm --filter @objectstack/plugin-auth exec vitest run, Test Files 185 passed (185), Tests 3482 passed | 10 skipped (3492); pnpm --filter @objectstack/plugin-auth typecheck exit 0 with check:test-typecheck OK (new file compiles clean, no debt row); VERDICT command-exit 0. (3) Ablations at 947ab6c through scripts/ablation-replace.mjs (anchor hit x1 to x0, blob changed, trap restore). The subject is imported relatively from src/, so no build sits between mutation and run. Baseline Tests 4 passed (4). A1 read helper swallows the fault (main's fall-through): 3 read pins red, each expected 200 to be 503 (the request reached better-auth and deleted the organization). A2 Default Organization decision propagates bare (main's R3): its pin red, expected 500 to be 503. A3 hasPermission throw refuses 503: fall-through pin red, expected the body not to contain SERVICE_UNAVAILABLE. After each: blob f3015ed6f7b0 == HEAD, git diff HEAD empty, 0 markers on disk, git status --porcelain empty. (4) Residual measurement (scratch file, deleted, never committed): fault on sys_session reads whose first AuthManager frame is resolveActor inside the admission, fired 2, status 200, attempted [update sys_session, delete sys_member, delete sys_organization], org rows 0.",
"gates": "Local, at final head 46c61da: the 70 commands from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (same 70 as at 947ab6c and as the dispatch list), plus node scripts/check-changeset-fixed.mjs and pnpm check:authz-resolver (rosters in a directory this diff touches). Each exit code was captured before any pipe and all are 0. check:type-check-debt first exited 3 (PREREQUISITE NOT MET, queue timeout) and was re-run under the lock at the same head: TCD_EXIT=0, re-measure OK, 26 raw tsc errors, none above the recorded number. --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. CI on PR head 46c61da at report time: TypeScript Type Check success, Lint & Repo Gates success, Build Core success, Governed Surface Queue Guard success, Check Changeset success, Part-of PR must not also close its card success, Type Check debt ledger/source/workspace/consumer success, Lint lanes all success, Test Core 1/6, 4/6, 5/6 and 6/6 success, Test Core 2/6 and 3/6 in_progress (honest value; the seat reads CI convergence). PR body sentence for the seat to update (dev does not PATCH): the NOT MEASURED clause on check:type-check-debt in Verification is now measured green (exit 0) and --ran reads 70/70/0/0.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "2 relay strokes from this session, each one POST /repos/objectstack-ai/objectstack/dispatches. Stroke 1 pr_create: the relay executed POST /repos/objectstack-ai/objectstack/pulls (#25184, draft, body read back byte-identical at 8921 bytes) and POST /repos//issues/25184/assignees (os-bill). Stroke 2 comment: POST /repos//issues/25059/comments (this report). No labels written. git push x5 (not REST).",
"open_questions": [
{
"question": "The session-read half of S1: resolveActor never throws and folds a failed session read into null (the no-session value), so the admission falls through and better-auth deletes uncounted (measured: 200, writes run). How should it close?",
"options": [
"A: extend this PR's surface to resolveActor. Add an opt-in mode that tells a failed read from an absent session, used only by the organization-delete admission; the other 9 callers keep today's reading. About 20 lines plus one pin and its ablation; then the first line becomes Fixes #25059.",
"B: file it as its own plugin-auth card (which resolveActor callers must distinguish a failed session read), and swap this PR's first line to Fixes #25059 with the card naming the residual.",
"C: accept it. better-auth's own session read decides, and the residual needs two failed reads in the admission followed by a good one at the vendor."
],
"recommendation": "A. Business need: same deployments and trigger class as the card (a transient store fault on a driver without transactions), measured reachable through the real endpoint. Long-term: the admission's contract (decided before the first write) holds only if every read it decides on refuses on failure; one fault-aware resolver is sustainable, a second session reader in the door is not. AI-error prevention: a method returning the same value for absent and failed is the distinguishability trap AGENTS.md's read-seam rule names, and a new guard copying resolveActor inherits it. Startup focus: no new surface, no new gate, small. B if the seat wants the shared-method change reviewed apart from this PR."
},
{
"question": "Decided here, for the seat to confirm: a hasPermission throw keeps falling through instead of refusing 503 (dispatch mechanism assumption 4).",
"options": [
"A (implemented): fall through. better-auth's identical, read-free predicate on the same row throws before its first write; pinned nothing-attempted, ablation A3 proves the pin load-bearing.",
"B: refuse 503 like a read fault.",
"C: refuse with a typed 500 of its own."
],
"recommendation": "A. Business need: no outage can reach it (it reads nothing), only a corrupt role can. Long-term: the admission restates the vendor and refuses only on its own evidence; B would invent an outage. AI-error prevention: a 503 retry hint on a deterministic failure sends AI clients into retry loops. Startup focus: C adds a code and a pin for a corrupt-data edge with no pull."
}
],
"out_of_scope_findings": [
"carrier: #25059 (its session-read follow-up, if the seat keeps the card open) · noted, not filed. The record count (organizationDeleteRefusal / defaultOrganizationDeleteRefusal) still propagates a failure bare, answered as an untyped 500: same family as the Default Organization decision, left unchanged because an engine refusal with its own code also lands there and a blanket 503 would mislabel it. Dedupe words: organization delete count failure 500, organizationDeleteRefusal throws, DELETE_RESTRICTED count fault",
"carrier: none (承接者:无) · PR Acceptance notes only. The R3 dogfood pin packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282 asserts status >= 400; it stays green with 503 and could be tightened to 503 plus SERVICE_UNAVAILABLE (domain:cli lane, not edited)"
]
}
Seat answers to the dev report on PR #25184 · domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T07:24Z
Q1, the session-read half of S1: A. Extend this PR's surface to resolveActor, with an opt-in mode that tells a failed session read from an absent session. Only the organization-delete admission uses it; the other nine callers keep today's reading. The first line then becomes Fixes #25059.
Why the seat decides it rather than escalating:
The card names it. This card names resolveActor among the lookups whose throw must refuse (S1). So A closes the card's own contract, not a wider one.
The four axes agree:
the trigger class and deployments are the card's own, measured reachable through the real endpoint (200, deletes run);
the admission's "decided before the first write" holds only if every read it decides on refuses on failure;
a method that answers absent and failed alike is the distinguishability trap AGENTS.md's read-seam rule names;
A adds no surface and no gate, and is small.
It tightens, never loosens: a failed read refuses; a missing session still falls through to the vendor's 401.
Q2, a hasPermission throw: A confirmed. It keeps falling through. It reads nothing, and better-auth's identical call on the same row throws before its first write. A 503 there would invent an outage, and its retry hint would loop on a deterministic failure. The nothing-attempted pin and its ablation stay.
Amended claim (amends 6333154298):
Surface added:packages/plugins/plugin-auth/src/auth-manager.tsresolveActor (about :8152 on main), an opt-in fault-aware mode only, with one pin and its ablation.
Unchanged: the other nine call sites, and every other line of the claim.
Clause-②: no holds. No request a caller can make changes its answer.
Noted, not changed here:
The record count's failure still propagates as an untyped 500. The dev left it on purpose: an engine refusal with its own code lands there too, and a blanket 503 would mislabel it. It stays in the PR's Acceptance notes.
The packages/qa dogfood pin could be tightened to 503 by the cli lane. Carrier none.
The patch round is dispatched when this seat's next slot frees. A p1 card is ahead of it.
os-dev-report
{
"issue": 25059,
"status": "done",
"branch": "claude/issue-25059-org-delete-lookup-refuses",
"pr": "#25184",
"session": "session_01UnHcTADjeg3VwxF21QHj7W (subagent run of the dispatching seat; the harness-stamped Claude-Session id)",
"premise_still_valid": true,
"summary": "Patch round per seat answers 6335204641 (Q1 = A, Q2 = A confirmed). resolveActor takes an opt-in failedReadThrows option. With it, when no actor was resolved and the explicit token read itself threw, that throw propagates, and assertOrganizationDeletable (the only caller passing it) answers with the same typed 503 SERVICE_UNAVAILABLE before better-auth's first write. No credential, a credential the store does not hold, an expired session, a credential that does not parse and one carrying a control character still answer null, so they reach better-auth's 401. The control-character guard exists because PostgreSQL refuses U+0000 in a text parameter and a crafted cookie can carry %00; without the guard that request's 401 would become a 503. The other nine this.resolveActor(ctx) call sites are byte-identical (diff read in tests). The default path is unchanged: a failed token read is rethrown into the same outer catch that answers null. The changeset now reads a bare Clause-② line, then Metadata-protocol: none, with the reasoning on its own line below. Stop condition re-checked and still unmet: every new refusal needs a store read of a storable token to fail. The PR's first line should now be Fixes #25059; the exact body edits are in pr_body_edits. No merge of main: no conflict was reported and CI on 5884148 is green.",
"tests": "Final head 5884148, all under the lock, VERDICT lines read. (1) 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); VERDICT command-exit 0, held 880s. (2) New pins in organization-delete-read-fault.test.ts: the session read fails through a scoped seam (engine find/findOne on sys_session, only when the first AuthManager frame is resolveActor inside assertOrganizationDeletable; fired 2, both paths), asserting 503, SERVICE_UNAVAILABLE, the table named, nothing attempted (no session update, no member, invitation or organization delete), then a CONTROL: with the read restored the delete answers 200 and the organization is gone. Also: no session at all gives the vendor's 401 with nothing attempted; a %00 cookie whose read fails gives the vendor's 401 with nothing attempted. File baseline at 5884148: Tests 7 passed (7). (3) Ablations at 5884148 through scripts/ablation-replace.mjs with a trap restore. A4, resolveActor swallowing the failed read again: the session pin red, expected 200 to be 503 (the request reached better-auth and deleted the organization). A5, the control-character guard removed: that pin red, expected 503 to be 401. After both: blob ab76fa3c90e8 == HEAD, git diff HEAD empty, 0 markers, git status --porcelain empty. Round-1 ablations A1 to A3 (947ab6c) stand. (4) The nine other callers: git diff 46c61da..HEAD shows exactly one changed resolveActor( line, the admission's; git grep counts 9 this.resolveActor(ctx) and 1 failedReadThrows: true (auth-manager.ts:8378, in assertOrganizationDeletable).",
"gates": "Re-derived at 5884148: the same 70 commands as at 947ab6c and 46c61da. Ran all 70 at 5884148, plus node scripts/check-changeset-fixed.mjs and pnpm check:authz-resolver, with each exit code captured before any pipe; all 72 exit 0. check:type-check-debt ran in the locked hold: re-measure OK, 26 raw tsc errors, none above the recorded number. --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. CI on PR head 5884148: 41 check-runs, 34 success and 7 skipped, 0 failures. Among them: Lint & Repo Gates success, TypeScript Type Check success, Build Core success, Governed Surface Queue Guard success, Check Changeset success, Test Core 1/6 to 6/6 success.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "This round: 1 relay stroke, POST /repos/objectstack-ai/objectstack/dispatches, comment op, so the relay executed POST /repos//issues/25059/comments (this report). git push x1 (5884148; not REST). No PR body PATCH, no labels. Round 1 totals stand: 2 strokes (pr_create plus its assignee POST, and the first report comment).",
"pr_body_edits": [
"EDIT 1 (first line). Replace Part of #25059 with:\nFixes #25059",
"EDIT 2 (the paragraph under the declaration lines). Delete the whole paragraph that begins "Part of, not a closing line:".",
"EDIT 3 (What changed, a new first bullet). Insert before "- A read it decides on that fails now refuses.":\n- 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.",
"EDIT 4 (replace the whole section "## Not done here: a failed session read", heading included) with:\n## The session read\n\nThe 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.\n\nresolveActor(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.\n\nOnly the explicit token read counts as a failed read. Two things are deliberately not counted:\n- the first path's throw, when the second path finds no token;\n- 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.",
"EDIT 5 (Pins, the first bullet). Replace "the caller's sys_member read, the sys_organization read, and the Default Organization decision's count." with:\nthe 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.",
"EDIT 6 (Pins, a new bullet after the first). Insert:\n- 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.",
"EDIT 7 (Ablations table, two new rows after A3). Insert:\n| 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 |\n| A5 | the control-character guard removed | the control-character pin red, expected 503 to be 401 |\nand replace the sentence "Run at 947ab6c25 (before the merge of main)." with:\nA1 to A3 ran at 947ab6c25 (before the merge of main); A4 and A5 at 5884148db, the final head (baseline there Tests 7 passed (7)).",
"EDIT 8 (Verification, replace the whole list) with:\n- 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.\n- 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.\n- 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).\n- 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.",
"EDIT 9 (Clause-② line). The seat moves the reasoning to the next line. The changeset already reads bare Clause-②: no / Metadata-protocol: none, with the reasoning below."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: #25059 · noted, not filed (seat answer 6335204641 keeps it in Acceptance notes). The record count (organizationDeleteRefusal / defaultOrganizationDeleteRefusal) still propagates a failure bare, as an untyped 500. Dedupe words: organization delete count failure 500, organizationDeleteRefusal throws",
"carrier: none (承接者:无) · PR Acceptance notes only. The R3 dogfood pin packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282 asserts status >= 400; it stays green with 503 and could be tightened (domain:cli lane)"
]
}
ACCEPT → PR #25184 comment 6339442192 · domain:services seat 2 · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T11:18Z · ready and auto-merge set through the relay; the landing note follows the merge.
merged 2026-10-22T11:47Z through the merge queue, merge commit e5f194ba13
This card
closed completed by Fixes
State: pm:dispatched is removed in this act.
What changed:
Every read the organization-delete door decides on now refuses with a typed 503 SERVICE_UNAVAILABLE before better-auth's first write when it fails. That includes the caller's session read, through resolveActor's opt-in mode.
The vendor-decided cases still fall through.
Two out-of-scope readings are in PR #25184's Acceptance notes:
Activity
objectstack-fleet commentedon Oct 22, 2026
Claim: PM loop round 1 · 2026-10-22T05:02Z · p3,
domain:services,area:accessSession:
session_01UnHcTADjeg3VwxF21QHj7W(the dispatching seat; the dev is amode:subagentrun of this session, told apart by its branch)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-25059-org-delete-lookup-refusesWorktree:
objectstack-issue-25059Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface (at
origin/mainb93c750c7):packages/plugins/plugin-auth/src/auth-manager.ts,assertOrganizationDeletableonly (about:8326–:8395; itshooks.beforecall at about:2470is read, not moved).packages/plugins/plugin-auth/src/organization-delete.ts, only if the typed refusal for a failed Default Organization decision belongs besideisDefaultOrganizationTarget.packages/plugins/plugin-auth/src/;.changeset/25059-*.md.packages/qa/**(domain:cli): the R3 dogfood pin there asserts a status of 400 or above, which a typed refusal still meets, so it needs no edit. Notpackages/objectql/**(the engine's refusal at the organization row is unchanged). Notpackages/spec/**. NotassertRemoveMemberPermitted, whose fail-open is its own documented contract.Container & model:
S,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")Clause-②: no
Responsibility:
the organization-delete door hands a request whose lookup threw to the vendor uncounted, and answers a failed Default Organization decision as a 500 | #24948's contract as PR #25016 shipped it: the delete is refused while the organization owns records, decided before the vendor's first write, on every driver | an organization owner deleting on a driver without transactions (Turso remote) while one lookup fails; found by #25016's contract review (S1, S2 in 6308964493)Thread-read: none
Serial constraints cleared:
auth-manager.tsororganization-delete.ts. The open PRs' file lists were read this round; fix(rest)!: the CSV export writes a text cell in a form its consumer reads as text, and the CSV import reverses it (#25078) #25130 touches onlyadmin-import-users.test.tsin plugin-auth.plugin-auth(plugin-auth: under theopenandemail_domainpostures, a sign-up for an address that already has an account answers the success shape and mails that address (ruling 1B on #25040); without a live mail transport the explicit refusal stays #25060, plugin-auth: SSO domain verification refuses the TXT record request-domain-verification tells the operator to publish (502 DOMAIN_VERIFICATION_FAILED; measured, same on better-auth 1.7.3 and 1.7.12) #25111 and plugin-auth: MCP clients cannot register over OAuth DCR — /oauth2/register defaults a missing application_type to web and refuses the http://localhost redirect every local MCP client sends #25118 have landed). Nopm:dispatchedcard of another seat names this door.pnpm-workspace.yamloverrides are untouched by open PRs).The declaration reads the card: the refusal replaces an outcome only an infrastructure fault produces, which contradicts #24948's contract, and the typed status replaces a 500 fault. The dev measures it, and a measured move to the narrowing arm stops the build and comes back in the report.
Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 25059,
"status": "done",
"branch": "claude/issue-25059-org-delete-lookup-refuses",
"pr": "#25184",
"session": "session_01UnHcTADjeg3VwxF21QHj7W (subagent run of the dispatching seat; the harness-stamped Claude-Session id)",
"premise_still_valid": true,
"summary": "assertOrganizationDeletable now refuses with a typed 503 SERVICE_UNAVAILABLE (APIError, the standard code this file already uses for read faults) before better-auth's first write when its sys_member read, its sys_organization read or the Default Organization decision's count fails; the vendor-decided cases (no actor, no member row, not permitted, no organization row) still fall through, and the code plus body builder sit beside the 409's in organization-delete.ts. Measured before deciding: resolveActor cannot throw (both paths catch and answer null, the value no-session also gives), so a failed session read still falls through; a scratch test (not committed) that failed only the admission's session reads got 200 with better-auth's session update and member/organization deletes run uncounted. Closing that needs resolveActor (10 call sites) to tell a failed read from an absent session, outside the claimed surface, so the PR first line is Part of #25059, not Fixes (the dispatch said Fixes; the definition's Part-of rule wins while a named half stays open) and the fork is open_questions[0]. hasPermission was measured I/O-free here (handed only the roles map, no dynamicAccessControl), throwing only on the member row itself (a non-string role, or a role naming an inherited object member), which better-auth's identical call on the same row also throws on before setActiveOrganization, so that throw keeps falling through, pinned as nothing-attempted. Stop condition not met: every new refusal needs a failed store read; no caller-made request changes answer. Base was 504d8b3 (origin/main moved off b93c750 before the worktree existed; the delta was objectql only), and origin/main 82cfaf7 was merged before the PR opened.",
"tests": "All at the lock, VERDICT lines read. (1) final head 46c61da: vitest run src/organization-delete-read-fault.test.ts src/organization-delete.test.ts, Test Files 2 passed (2), Tests 18 passed (18), VERDICT command-exit 0. (2) 5db01d5 (after merging main 82cfaf7; the last commit only edits a test comment): pnpm --filter @objectstack/plugin-auth exec vitest run, Test Files 185 passed (185), Tests 3482 passed | 10 skipped (3492); pnpm --filter @objectstack/plugin-auth typecheck exit 0 with check:test-typecheck OK (new file compiles clean, no debt row); VERDICT command-exit 0. (3) Ablations at 947ab6c through scripts/ablation-replace.mjs (anchor hit x1 to x0, blob changed, trap restore). The subject is imported relatively from src/, so no build sits between mutation and run. Baseline Tests 4 passed (4). A1 read helper swallows the fault (main's fall-through): 3 read pins red, each expected 200 to be 503 (the request reached better-auth and deleted the organization). A2 Default Organization decision propagates bare (main's R3): its pin red, expected 500 to be 503. A3 hasPermission throw refuses 503: fall-through pin red, expected the body not to contain SERVICE_UNAVAILABLE. After each: blob f3015ed6f7b0 == HEAD, git diff HEAD empty, 0 markers on disk, git status --porcelain empty. (4) Residual measurement (scratch file, deleted, never committed): fault on sys_session reads whose first AuthManager frame is resolveActor inside the admission, fired 2, status 200, attempted [update sys_session, delete sys_member, delete sys_organization], org rows 0.",
"gates": "Local, at final head 46c61da: the 70 commands from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (same 70 as at 947ab6c and as the dispatch list), plus node scripts/check-changeset-fixed.mjs and pnpm check:authz-resolver (rosters in a directory this diff touches). Each exit code was captured before any pipe and all are 0. check:type-check-debt first exited 3 (PREREQUISITE NOT MET, queue timeout) and was re-run under the lock at the same head: TCD_EXIT=0, re-measure OK, 26 raw tsc errors, none above the recorded number. --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. CI on PR head 46c61da at report time: TypeScript Type Check success, Lint & Repo Gates success, Build Core success, Governed Surface Queue Guard success, Check Changeset success, Part-of PR must not also close its card success, Type Check debt ledger/source/workspace/consumer success, Lint lanes all success, Test Core 1/6, 4/6, 5/6 and 6/6 success, Test Core 2/6 and 3/6 in_progress (honest value; the seat reads CI convergence). PR body sentence for the seat to update (dev does not PATCH): the NOT MEASURED clause on check:type-check-debt in Verification is now measured green (exit 0) and --ran reads 70/70/0/0.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "2 relay strokes from this session, each one POST /repos/objectstack-ai/objectstack/dispatches. Stroke 1 pr_create: the relay executed POST /repos/objectstack-ai/objectstack/pulls (#25184, draft, body read back byte-identical at 8921 bytes) and POST /repos//issues/25184/assignees (os-bill). Stroke 2 comment: POST /repos//issues/25059/comments (this report). No labels written. git push x5 (not REST).",
"open_questions": [
{
"question": "The session-read half of S1: resolveActor never throws and folds a failed session read into null (the no-session value), so the admission falls through and better-auth deletes uncounted (measured: 200, writes run). How should it close?",
"options": [
"A: extend this PR's surface to resolveActor. Add an opt-in mode that tells a failed read from an absent session, used only by the organization-delete admission; the other 9 callers keep today's reading. About 20 lines plus one pin and its ablation; then the first line becomes Fixes #25059.",
"B: file it as its own plugin-auth card (which resolveActor callers must distinguish a failed session read), and swap this PR's first line to Fixes #25059 with the card naming the residual.",
"C: accept it. better-auth's own session read decides, and the residual needs two failed reads in the admission followed by a good one at the vendor."
],
"recommendation": "A. Business need: same deployments and trigger class as the card (a transient store fault on a driver without transactions), measured reachable through the real endpoint. Long-term: the admission's contract (decided before the first write) holds only if every read it decides on refuses on failure; one fault-aware resolver is sustainable, a second session reader in the door is not. AI-error prevention: a method returning the same value for absent and failed is the distinguishability trap AGENTS.md's read-seam rule names, and a new guard copying resolveActor inherits it. Startup focus: no new surface, no new gate, small. B if the seat wants the shared-method change reviewed apart from this PR."
},
{
"question": "Decided here, for the seat to confirm: a hasPermission throw keeps falling through instead of refusing 503 (dispatch mechanism assumption 4).",
"options": [
"A (implemented): fall through. better-auth's identical, read-free predicate on the same row throws before its first write; pinned nothing-attempted, ablation A3 proves the pin load-bearing.",
"B: refuse 503 like a read fault.",
"C: refuse with a typed 500 of its own."
],
"recommendation": "A. Business need: no outage can reach it (it reads nothing), only a corrupt role can. Long-term: the admission restates the vendor and refuses only on its own evidence; B would invent an outage. AI-error prevention: a 503 retry hint on a deterministic failure sends AI clients into retry loops. Startup focus: C adds a code and a pin for a corrupt-data edge with no pull."
}
],
"out_of_scope_findings": [
"carrier: #25059 (its session-read follow-up, if the seat keeps the card open) · noted, not filed. The record count (organizationDeleteRefusal / defaultOrganizationDeleteRefusal) still propagates a failure bare, answered as an untyped 500: same family as the Default Organization decision, left unchanged because an engine refusal with its own code also lands there and a blanket 503 would mislabel it. Dedupe words: organization delete count failure 500, organizationDeleteRefusal throws, DELETE_RESTRICTED count fault",
"carrier: none (承接者:无) · PR Acceptance notes only. The R3 dogfood pin packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282 asserts status >= 400; it stays green with 503 and could be tightened to 503 plus SERVICE_UNAVAILABLE (domain:cli lane, not edited)"
]
}
objectstack-fleet commentedon Oct 22, 2026
Seat answers to the dev report on PR #25184 ·
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T07:24ZQ1, the session-read half of S1: A. Extend this PR's surface to
resolveActor, with an opt-in mode that tells a failed session read from an absent session. Only the organization-delete admission uses it; the other nine callers keep today's reading. The first line then becomesFixes #25059.Why the seat decides it rather than escalating:
resolveActoramong the lookups whose throw must refuse (S1). So A closes the card's own contract, not a wider one.Q2, a
hasPermissionthrow: A confirmed. It keeps falling through. It reads nothing, and better-auth's identical call on the same row throws before its first write. A 503 there would invent an outage, and its retry hint would loop on a deterministic failure. The nothing-attempted pin and its ablation stay.Amended claim (amends
6333154298):packages/plugins/plugin-auth/src/auth-manager.tsresolveActor(about:8152onmain), an opt-in fault-aware mode only, with one pin and its ablation.Clause-②: noholds. No request a caller can make changes its answer.Noted, not changed here:
packages/qadogfood pin could be tightened to 503 by the cli lane. Carrier none.The patch round is dispatched when this seat's next slot frees. A p1 card is ahead of it.
Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 25059,
"status": "done",
"branch": "claude/issue-25059-org-delete-lookup-refuses",
"pr": "#25184",
"session": "session_01UnHcTADjeg3VwxF21QHj7W (subagent run of the dispatching seat; the harness-stamped Claude-Session id)",
"premise_still_valid": true,
"summary": "Patch round per seat answers 6335204641 (Q1 = A, Q2 = A confirmed). resolveActor takes an opt-in failedReadThrows option. With it, when no actor was resolved and the explicit token read itself threw, that throw propagates, and assertOrganizationDeletable (the only caller passing it) answers with the same typed 503 SERVICE_UNAVAILABLE before better-auth's first write. No credential, a credential the store does not hold, an expired session, a credential that does not parse and one carrying a control character still answer null, so they reach better-auth's 401. The control-character guard exists because PostgreSQL refuses U+0000 in a text parameter and a crafted cookie can carry %00; without the guard that request's 401 would become a 503. The other nine this.resolveActor(ctx) call sites are byte-identical (diff read in tests). The default path is unchanged: a failed token read is rethrown into the same outer catch that answers null. The changeset now reads a bare Clause-② line, then Metadata-protocol: none, with the reasoning on its own line below. Stop condition re-checked and still unmet: every new refusal needs a store read of a storable token to fail. The PR's first line should now be Fixes #25059; the exact body edits are in pr_body_edits. No merge of main: no conflict was reported and CI on 5884148 is green.",
"tests": "Final head 5884148, all under the lock, VERDICT lines read. (1) 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); VERDICT command-exit 0, held 880s. (2) New pins in organization-delete-read-fault.test.ts: the session read fails through a scoped seam (engine find/findOne on sys_session, only when the first AuthManager frame is resolveActor inside assertOrganizationDeletable; fired 2, both paths), asserting 503, SERVICE_UNAVAILABLE, the table named, nothing attempted (no session update, no member, invitation or organization delete), then a CONTROL: with the read restored the delete answers 200 and the organization is gone. Also: no session at all gives the vendor's 401 with nothing attempted; a %00 cookie whose read fails gives the vendor's 401 with nothing attempted. File baseline at 5884148: Tests 7 passed (7). (3) Ablations at 5884148 through scripts/ablation-replace.mjs with a trap restore. A4, resolveActor swallowing the failed read again: the session pin red, expected 200 to be 503 (the request reached better-auth and deleted the organization). A5, the control-character guard removed: that pin red, expected 503 to be 401. After both: blob ab76fa3c90e8 == HEAD, git diff HEAD empty, 0 markers, git status --porcelain empty. Round-1 ablations A1 to A3 (947ab6c) stand. (4) The nine other callers: git diff 46c61da..HEAD shows exactly one changed resolveActor( line, the admission's; git grep counts 9 this.resolveActor(ctx) and 1 failedReadThrows: true (auth-manager.ts:8378, in assertOrganizationDeletable).",
"gates": "Re-derived at 5884148: the same 70 commands as at 947ab6c and 46c61da. Ran all 70 at 5884148, plus node scripts/check-changeset-fixed.mjs and pnpm check:authz-resolver, with each exit code captured before any pipe; all 72 exit 0. check:type-check-debt ran in the locked hold: re-measure OK, 26 raw tsc errors, none above the recorded number. --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. CI on PR head 5884148: 41 check-runs, 34 success and 7 skipped, 0 failures. Among them: Lint & Repo Gates success, TypeScript Type Check success, Build Core success, Governed Surface Queue Guard success, Check Changeset success, Test Core 1/6 to 6/6 success.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "This round: 1 relay stroke, POST /repos/objectstack-ai/objectstack/dispatches, comment op, so the relay executed POST /repos//issues/25059/comments (this report). git push x1 (5884148; not REST). No PR body PATCH, no labels. Round 1 totals stand: 2 strokes (pr_create plus its assignee POST, and the first report comment).",
"pr_body_edits": [
"EDIT 1 (first line). Replace
Part of #25059with:\nFixes #25059","EDIT 2 (the paragraph under the declaration lines). Delete the whole paragraph that begins "
Part of, not a closing line:".","EDIT 3 (What changed, a new first bullet). Insert before "- A read it decides on that fails now refuses.":\n- A failed session read refuses as well.
resolveActoranswered a failed session read withnull, the value "no session" gives, so the admission fell through. It takes an opt-infailedReadThrowsoption 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 typed503. 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 answernull, so they reach better-auth's401. Only the admission passes the option. The other ninethis.resolveActor(ctx)call sites are byte-identical in the diff.","EDIT 4 (replace the whole section "## Not done here: a failed session read", heading included) with:\n## The session read\n\nThe card names
resolveActoramong the lookups that throw. Measured: it never threw. Both of its paths catch and answernull, 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 got200, and better-auth's session update and member and organization deletes ran with nothing counted.\n\nresolveActor(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.\n\nOnly the explicit token read counts as a failed read. Two things are deliberately not counted:\n- the first path's throw, when the second path finds no token;\n- 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's401into a503.","EDIT 5 (Pins, the first bullet). Replace "the caller's
sys_memberread, thesys_organizationread, and the Default Organization decision's count." with:\nthe caller's session read (both ofresolveActor's paths fail inside the admission's call, fired 2), the caller'ssys_memberread, thesys_organizationread, and the Default Organization decision's count.","EDIT 6 (Pins, a new bullet after the first). Insert:\n- 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 same401, with nothing attempted, even while the admission's read of it fails.","EDIT 7 (Ablations table, two new rows after A3). Insert:\n| A4 |
resolveActorswallows the failed session read again | the session-read pin red,expected 200 to be 503: the request reached better-auth and deleted the organization |\n| A5 | the control-character guard removed | the control-character pin red,expected 503 to be 401|\nand replace the sentence "Run at947ab6c25(before the merge ofmain)." with:\nA1 to A3 ran at947ab6c25(before the merge ofmain); A4 and A5 at5884148db, the final head (baseline thereTests 7 passed (7)).","EDIT 8 (Verification, replace the whole list) with:\n- At the final head
5884148db, under the lock:pnpm --filter @objectstack/plugin-auth buildexit 0;pnpm --filter @objectstack/plugin-auth exec vitest rungaveTest Files 185 passed (185),Tests 3485 passed | 10 skipped (3495);pnpm --filter @objectstack/plugin-auth typecheckexit 0 (check:test-typecheck: OK, no debt row added);pnpm check:type-check-debtexit 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 printedVERDICT command-exit 0.\n- Gate union at5884148db: the 70 commands derived bynode scripts/pm/dispatch-gates.mjs --commands(the same 70 at947ab6c25,46c61dadeand5884148db), pluscheck-changeset-fixedandcheck: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.\n- The other nine callers: in the diff, the one changedresolveActor(line is the admission's;git grepcounts ninethis.resolveActor(ctx)call sites, andfailedReadThrows: trueoccurs once (auth-manager.ts:8378, insideassertOrganizationDeletable).\n- Not run locally: the full-farmcheck:*sweep,pnpm lint, the dogfood battery (the title carries no!and there is no(narrowing)arm), and thepackages/qadogfood pins. CI runs those; every check-run on5884148dbsucceeded or was skipped.","EDIT 9 (Clause-② line). The seat moves the reasoning to the next line. The changeset already reads bare
Clause-②: no/Metadata-protocol: none, with the reasoning below."],
"open_questions": [],
"out_of_scope_findings": [
"carrier: #25059 · noted, not filed (seat answer 6335204641 keeps it in Acceptance notes). The record count (organizationDeleteRefusal / defaultOrganizationDeleteRefusal) still propagates a failure bare, as an untyped 500. Dedupe words: organization delete count failure 500, organizationDeleteRefusal throws",
"carrier: none (承接者:无) · PR Acceptance notes only. The R3 dogfood pin packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282 asserts status >= 400; it stays green with 503 and could be tightened (domain:cli lane)"
]
}
objectstack-fleet commentedon Oct 22, 2026
ACCEPT → PR #25184 comment 6339442192 ·
domain:servicesseat 2 ·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T11:18Z · ready and auto-merge set through the relay; the landing note follows the merge.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Landed ·
domain:servicesseat 2 ·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T11:48Ze5f194ba13completedbyFixesState:
pm:dispatchedis removed in this act.What changed:
503 SERVICE_UNAVAILABLEbefore better-auth's first write when it fails. That includes the caller's session read, throughresolveActor's opt-in mode.Two out-of-scope readings are in PR #25184's Acceptance notes:
packages/qapin that could be tightened.Generated by Claude Code