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
admin-lifecycle: remove-user is non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724
As a genuine better-auth admin, POST /api/v1/auth/admin/remove-user {userId} on a user that owns a record fails, and the failure is non-atomic:
Observed: HTTP 500 with an empty body. sys_user is still readable; the owned record still carries its owner_id; and the user's credential sys_account rows have already been deleted — so that email can no longer sign in (401) while still occupying the org roster. Reproduced twice; verified across three victims (accounts=0 for all three vs accounts=1 for untouched users).
Expected: 2xx, sys_user gone, the owned record surviving with owner_id cleared to null (engine FK clear).
The server log gives the real cause — a 409 leaking to the client as a bodyless 500:
Cannot delete sys_user (): 1 dependent sys_member record(s) reference it via user_id
(user_id is required, so it cannot be cleared)… set deleteBehavior:cascade on sys_member.user_id
{ code: DELETE_RESTRICTED, status: 409 }
Root cause
Three compounding problems, located on origin/main:
Every user is auto-bound to the default org at sign-up/create (the membership reconciler in packages/plugins/plugin-auth/src/reconcile-membership.ts runs as a user.create.after hook), and sys_member.user_id is a required FK with no cascade — packages/platform-objects/src/identity/sys-member.object.ts declares user_id: Field.lookup('sys_user', { required: true }) and sets nodeleteBehavior:'cascade'. So the engine's referential restrict vetoes the sys_user delete every time — remove-user can never succeed on this deployment.
The operator cannot clear the blocker either.DELETE /api/v1/data/sys_member/{id} → 405 — sys-member.object.ts sets enable.apiMethods = ['get', 'list'] (writes owned by better-auth / refused by the identity write guard).
No rollback. The account/session deletions performed by better-auth's admin remove-user handler are not rolled back when the engine refuses the sys_user delete, leaving an orphaned, un-authenticatable identity on the roster.
On top of that, the engine's DELETE_RESTRICTED (409) surfaces through the better-auth admin route as a bodyless HTTP 500 rather than a structured 409 envelope (the generic REST data route maps this correctly in packages/rest/src/rest-server.ts, but the better-auth admin path does not).
This is not the #3023 owner-anchor regression — the veto comes from the engine's referential restrict on sys_member.user_id, not from the owner-anchor transfer guard.
The break-glass guard at packages/plugins/plugin-auth/src/auth-manager.ts (ctx.path === '/admin/remove-user') refuses removing the last local-credential holder, but does not cascade sys_member, roll back the credential deletes, or map the 409 — so none of the three problems above are addressed by it.
Reproduction
As a genuine better-auth admin: POST /api/v1/auth/admin/create-user {email:…} → 200.
Create a record owned by that user.
POST /api/v1/auth/admin/remove-user {userId}.
Observe HTTP 500 with an empty body; re-read shows sys_user still present, the record still owned, but the user's sys_account rows gone (subsequent sign-in 401).
Related
Shares the bodyless-500 statusCode-leak class with the invitation-acceptance failure from the same run — #7725 (accept-invitation → bodyless 500 on a UNIQUE-constraint collision).
Source
Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).
PM triage (domain:identity seat, #6022, session session_01BVc1ekPpi6yaWywAUhfzfd): stays pm:queue, deliberately not dispatched yet — serialized behind #7725, which is in flight now.
The two cards are one family: both are rooted in the sign-up membership reconciler (reconcile-membership.ts) auto-binding every user to the default org, and both surface through the same bodyless-500 leak on the better-auth admin/organization routes. Dispatched in parallel they would produce two shapes for one problem on sys-member.object.ts and on the shared error mapping. #7725 owns acceptance-side adoption of the existing membership row and is explicitly forbidden from touching this card's surfaces.
This card's scope when it dispatches (recorded now so it is not re-derived later):
The sys_member.user_id referential-restrict veto — the engine's own error names deleteBehavior:'cascade' as the fix, and a membership row without its user is meaningless, so this looks decidable by the dev rather than by the maintainer; it will be dispatched with the instruction to verify that nothing depends on the restrict (e.g. as an accidental last-admin guard) before adding the cascade.
The non-atomicity — better-auth's remove-user deletes credential/session rows and does not roll them back when the engine refuses the sys_user delete, leaving an un-authenticatable identity on the roster. This is the harmful half: it fires today, on every attempt.
Also noted for whoever takes it: the operator cannot clear the blocker by hand either (DELETE /api/v1/data/sys_member/{id} → 405 from enable.apiMethods = ['get','list']), so "just delete the membership first" is not an available workaround, and the break-glass guard in auth-manager.ts addresses none of the three.
Input carried from the family's other half, now that #7725's PR (#7796) is reviewed and armed — read this before dispatching, it changes where the cheapest fix for problem 3 probably lives.
The bodyless-500 residue is confirmed, and it is not where the card assumed.#7725's dev traced it in better-auth 1.7.0-rc.2 source rather than inferring:
The rollback is better-auth's, not ours.crud-invites.mjs's runWithTransaction.catch sets the invitation back to pending (fromStatus: 'accepted') and rethrows. So fixing the error mapping alone makes the fault visible without stopping the state from reverting — worth knowing before someone concludes the mapping fix is sufficient.
The seam that produced the empty body may be one file away from the REST mapping.packages/plugins/plugin-auth/src/objectql-adapter.ts already has withValidationErrorMapping, which converts engine validation errors into better-auth APIErrors. A raw driver error — the UNIQUE violation here, and by extension a DELETE_RESTRICTED 409 — is not in that set, which is exactly why it escaped as a bodyless 500. Widening rethrowAsBetterAuthError may be cheaper and better-targeted than editing packages/rest/src/rest-server.ts's mapping, which was this card's assumed home for problem 3. Evaluate both when it dispatches; the reserved surface stays reserved either way.
What #7796 did NOT touch, so your three problems are all still yours:sys-member.object.ts is absent from its diff (FK, deleteBehavior, indexes untouched — the unique index is honoured, not modified), and the REST mapping is absent. Confirmed against the merged file list, not just the report.
One consequence of #7796 for this card's problem 1: acceptance now adopts the reconciler's row, so the "every user is already bound" invariant is now load-bearing on two paths rather than one. If you add deleteBehavior: 'cascade' to sys_member.user_id, the cascade will also remove memberships that acceptance adopted — which is correct (a membership without its user is meaningless) but means the delete path is now the only place that decides a membership's end, with no compensating record. Worth a test that removing a user who accepted an invitation leaves nothing behind.
sys-member.object.ts is not in #7309's file set, so the two cards touch disjoint files inside one package. Letting both run would be adopting the mutually-exclusive-region exemption, which has been raised with the maintainer and not ruled. The standing default is strict serialization, so the card waits. I am not taking the exemption on my own authority just because this card is the more serious of the two — that is precisely the pressure the default exists to resist.
Cost of the fence, stated plainly so the ruling can be made on evidence: this card is a live data-integrity defect — remove-user can never succeed on this deployment, and it deletes credential rows without rollback, leaving an un-authenticatable identity on the org roster. It is now waiting on a card about duplicate confirmation dialogs. If the exemption were granted, this would dispatch immediately.
Dispatch the moment #7309 lands (or the moment the exemption is ruled). Notes for whoever takes it:
Three compounding problems plus a status-code leak — the card is explicit that the break-glass guard in auth-manager.ts addresses none of them. Do not let a fix for the 500-envelope stand in for the atomicity fix; a structured 409 on an operation that can never succeed is still an operation that can never succeed.
Problem 2 (DELETE /api/v1/data/sys_member/{id} → 405, writes refused by the identity write guard) means the operator has no manual escape today. Whether that 405 is correct is a separate question from whether remove-user should cascade — settle the cascade first.
Claim: PM loop round 2 (domain:identity seat #6022)
Session: session_01D6Qi9sYxhaRwj7TYiD5MWg
Branch: claude/issue-7724-remove-user-atomicity
Worktree: objectstack-issue-7724
Domain: domain:identity
File surface: packages/platform-objects/src/identity/sys-member.object.ts (the deleteBehavior decision) · packages/plugins/plugin-auth/src/objectql-adapter.ts + rethrowAsBetterAuthError · packages/plugins/plugin-auth/src/auth-manager.ts if the rollback lands there · their tests. packages/rest/src/rest-server.ts is in surface only if the adapter seam turns out to be the wrong home for problem 3 — report before going there.
Container & model: M+ (three compounding problems + a data-integrity fix), mode:subagent(amendment, see below), model: opus
Serial constraints cleared: both fences named by the previous holder are now down.#7309 → PR #7827 landed as 098f4bb (that was the packages/platform-objects in-flight card this was fenced behind). #7725 → PR #7796 landed as c9c2d92. sys-member.object.ts last touched by 779ace4, an unrelated docs commit — free. No other in-flight claim in this lane names any of these files.
⚠️Dispatch-mode amendment: this card's sizing calls for mode:cloud, but the Claude_Code_Remote MCP server has been unreachable since ~21:30Z. Dispatching as a subagent under the standing fallback, recorded here rather than only in chat.
Premise re-verified on current origin/main, not inherited
The card is 10 hours old and main has taken ~20 merges since, four of them this lane's. Re-read by symbol:
sys_member.user_id is still Field.lookup('sys_user', {…}) at sys-member.object.ts:164, and no deleteBehavior is declared — the referential-restrict veto is live.
enable.apiMethods = ['get','list'] still at :202 — the operator still has no manual escape (problem 2 stands).
#7796 landed, and it moves where problem 3's fix probably belongs. Its dev traced the bodyless 500 in better-auth 1.7.0-rc.2 source: withValidationErrorMapping in objectql-adapter.ts converts engine validation errors into better-auth APIErrors, but a raw driver/engine error — the UNIQUE violation there, and by extension DELETE_RESTRICTED 409 here — is not in that set. That is why it escapes bodyless. So widening rethrowAsBetterAuthError is likely cheaper and better-targeted than editing the REST mapping, which is what this card originally assumed. Carried into the dispatch as a PM hypothesis to measure, not as a ruling.
And #7796 raised the stakes on problem 1. Acceptance now adopts the reconciler's membership row, so "every user is already bound" is load-bearing on two paths. A deleteBehavior: 'cascade' will therefore also remove memberships that acceptance adopted — correct (a membership without its user is meaningless), but it makes the delete path the only place a membership's end is decided, with no compensating record. The dispatch requires a test for exactly that: remove a user who accepted an invitation, and assert nothing is left behind.
The dispatched dev terminated with You've hit your session limit · resets 12:10am (UTC) — an account-wide quota exhaustion, not anything about this card. Its last output was "Premise 1 confirmed by symbol. Now the auth plugin surface."
Nothing survived, and that is verified rather than assumed — this matters, because the standing rule for a quota death is that the worktree keeps uncommitted work, and inherited work must then be reviewed as an untrusted draft:
So there is no inherited diff to distrust — the re-dispatch is a clean start, not a handoff. The branch was never pushed and no PR was opened.
Unchanged and still verified
The claim comment above stays valid as written. Both serialization fences remain down (#7309 → 098f4bb, #7725/#7796 → c9c2d92), sys-member.object.ts is still untouched by anything in flight, and the premise the dev had already re-confirmed by symbol before dying — the user_id lookup with no deleteBehavior, apiMethods: ['get','list'], the intact unique index — is the same premise the new dispatch carries.
The one thing that changed is the clock: main has taken further merges since the claim, so the new dev re-verifies against origin/main rather than against the claim's readings.
⛔ Assignee and claim untouched — this is a continuation of the existing claim, not a new one.
{
"issue": 7724,
"status": "done",
"branch": "claude/issue-7724-remove-user-atomicity",
"pr": "https://github.com/objectstack-ai/objectstack/pull/7879",
"premise_still_valid": true,
"summary": "All three compounding problems fixed, none standing in for another. (1) sys_member.user_id now declares deleteBehavior:'cascade' — audited first as the card required: the restrict is NOT an accidental last-admin guard, because that invariant is a beforeDelete hook registered on sys_member itself (last-admin-guard.ts:1178) and the engine's cascade recurses through the PUBLIC delete() so the hook still fires, one row deeper. (2) Non-atomicity: verified at source that better-auth 1.7.0-rc.2's internalAdapter.deleteUser deletes sessions, then accounts, then the user in three calls with the string 'transaction' absent from that file entirely; subject-erasure requests now run inside one ObjectQL.transaction opened at AuthManager.handleRequest, reusing the existing exported SESSION_ERASURE_PATHS rather than a new list. The non-obvious part: better-auth RETURNS its faults as a Response instead of throwing, so a plain try/catch unit of work would commit on exactly the path it exists to undo — rollback is therefore raised from the response status and the response carried back out through a private sentinel. (3) The PM's hypothesis about problem 3 was CORRECT: rethrowAsBetterAuthError mapped validation errors and policy refusals but not a raw engine DELETE_RESTRICTED, so it fell through to `throw err` and better-auth rendered a bodyless 500. A third arm maps it to APIError('CONFLICT') with developerMessage/dependentObject/dependentCount. packages/rest/src/rest-server.ts is UNTOUCHED. Scope held: the apiMethods/405 question was not widened (ruling 2) and the #3023 owner-anchor regression was not re-litigated (ruling 3).",
"tests": "New: packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts (6 cases, real better-auth pipeline through a real AuthManager) + 3 unit cases in objectql-adapter.test.ts. The card's REQUIRED case is covered: a user who ACCEPTED an invitation (#7796 adoption path, asserted as exactly one membership before removal) is removed with 200 and leaves no sys_member, no sys_account and no sys_session. ALL THREE ABLATIONS PREDICTED IN WRITING BEFORE MUTATING, then measured. P1 drop deleteBehavior:'cascade' — predicted tests 1/2 + declaration pin red with 409 DELETE_RESTRICTED; MATCHED ('expected 409 to be 200', developerMessage naming the cascade), plus ONE UNPREDICTED RED reported honestly: the structured-body test went 'expected 409 to be 403' because with the restrict live the membership veto fires BEFORE that test's own injected refusal — a different but still structured refusal wins, not a false pin. P2 drop the DELETE_RESTRICTED arm — predicted 3 unit tests red + end-to-end bodyless 500; MATCHED exactly: isAPIError false, statusCode undefined, 'expected 500 to be 409', and 'the response body is empty — the fault leaked unmapped' (the reported symptom reproduced verbatim). P3 bypass the transaction wrapper — predicted rollback test red with credential rows still deleted; MATCHED: \"expected [] to deeply equal [ '7a34TfsZskkvSXuNqDTWwsz8KvpE6UBe' ]\", plus the 409 test's trailing 'refused AND clean' assertion, which is the card's central point. The fake engine is non-theatre: it READS SysMember.fields.user_id and reproduces the engine's own escalation arithmetic (the ablation confirmed the undeclared field reads back 'set_null', which it escalates to restrict exactly as engine.ts does), its transaction really snapshots/restores, and both write verbs are pinned to assertEngineDeleteDispatch/assertEngineUpdateDispatch. GREEN: plugin-auth 1048/1048, platform-objects 339/339, both typecheck clean, check:nul-bytes, check:docs-audit-scope, check:engine-double-contract (163 pinned — my new fake registered as pinned), check:error-code-casing, eslint --no-inline-config on all 5 changed files (no inline disables used). CONSUMER SWEEP, PREFIX DIRECTION (--filter '...@objectstack/platform-objects' --filter '...@objectstack/plugin-auth' = the 51 packages that DEPEND ON these): full build 71/71, typecheck clean across all 51, tests EXIT=0 with 19,135 passed. typecheck caught a real defect the green suite could not see: the composed scope helpers are typed Promise of Promise of Response, which the previous single call site flattened with its await. CI: in_progress at report time — not polled, per the reporting contract.",
"open_questions": [
{
"question": "Problem 3's leak turned out to be ONE seam shared with #7725, not two — reporting it rather than acting on it, per ruling 4. Both cards' bodyless 500s come from the same fall-through in rethrowAsBetterAuthError (objectql-adapter.ts): #7725's UNIQUE violation and this card's DELETE_RESTRICTED are both raw engine/driver errors carrying neither the validation nor the policy-refusal signature. My third arm is keyed narrowly on code === 'DELETE_RESTRICTED', so it fixes this card only and leaves #7725's class still unmapped.",
"options": [
"A — leave as shipped: one narrow arm per engine code, added as each is evidenced. #7725 already landed (c9c2d92) and its own symptom is resolved at the invitation layer, so nothing is broken today.",
"B — follow-up card to generalise the seam: map any engine error carrying a numeric `status` in 4xx to the matching APIError, so the next engine code does not need a third incident to be discovered."
],
"recommendation": "A for this PR, and B as a separate PM-owned card if the PM wants it — merging the cards is explicitly a PM call, not mine. B is genuinely attractive (it is the contract-first shape: the engine already declares `status`, so consuming it beats enumerating codes) but it widens the blast radius to every better-auth route at once, and I have measured evidence for exactly two codes. That is not enough to justify remapping everything under one predicate in a card whose harmful half is elsewhere."
},
{
"question": "The transaction wrapper's atomicity depends on ObjectQL's ambient AsyncLocalStorage txStore surviving the await chain through better-auth's handler. Verified working on the real pipeline in tests; NOT verified under WebContainer, where this repo already documents that AsyncLocalStorage.run() does not propagate across await (auth-manager.ts installs a polyfill for better-auth's request-state store, but txStore has none).",
"options": [
"A — ship as is and record the gap (filed as #7880, `finding`, no pm:queue).",
"B — block this card until WebContainer is measured."
],
"recommendation": "A. The gap is inherited by every transaction() caller equally (convertLead, publishPackageDrafts, batchData), is not introduced here, and WebContainer is a dev/demo runtime — whereas the defect this card fixes fires on every real attempt today. I deliberately did not claim to have measured WebContainer, since I have no such runtime here."
}
],
"out_of_scope_findings": [
"filed as #7880: ObjectQL transaction()'s ambient txStore has no WebContainer AsyncLocalStorage polyfill, so a rollback there could silently cover no writes while reporting success — observation-class, `finding` label, no pm:queue, searched for duplicates first (none).",
"not filed (already recorded on this card and ruled out of scope by the PM): DELETE /api/v1/data/sys_member/{id} still answers 405 from enable.apiMethods = ['get','list'], so the operator still has no manual escape. With the cascade in place the escape is no longer needed for this defect, but the 405 question itself is untouched — deliberately, per ruling 2."
]
}
PM review: ACCEPT — PR #7879. Seat #6022, session session_01D6Qi9sYxhaRwj7TYiD5MWg. Pending CI convergence only; the ready-flip and queue are this seat's job, not the dev's.
Verified independently, not from the report
check
result
Path face — docs/adr/** / .claude/skills/** / skills/**
✅ zero hits; normal terminal path
First line Fixes #7724 (not Part of)
✅ correct — all three problems are implemented, so merging should close the card
Claim: packages/rest/src/rest-server.ts untouched
✅ 0 occurrences in the diff
Claim: cascade actually declared
✅ sys-member.object.ts:164, with the escalation mechanism written out at the site
Scope: apiMethods/405 not widened (ruling 2), #3023 not re-litigated (ruling 3)
✅ both held
The cascade audit is the part I most wanted and it came back non-obvious. The card required proving the restrict is not an accidental last-administrator guard before removing it. The answer is that the guard is a beforeDelete hook on sys_member itself, and the engine's cascade recurses through the publicdelete() — so the guard still fires, one row deeper. That is a real audit, not a reassurance: the naive reading ("cascade bypasses the child's hooks") would have been wrong in the dangerous direction.
The subtlety in problem 2 is the kind that silently defeats a fix. better-auth returns its faults as a Response rather than throwing, so a plain try/catch transaction wrapper would have committed on exactly the path it exists to undo — a rollback that never rolls back, green in every test that only checks the happy path. Raising rollback from the response status instead is the right shape.
Both honest deviations were reported rather than smoothed. The cascade ablation produced an unpredicted red (409 → 403, because the membership veto fires before that test's own injected refusal) and it was reported as an unpredicted red rather than back-filled into the prediction table. Same for the transaction ablation's trailing assertion — which is the card's thesis in one line: a correct 409 that still leaves the credential rows deleted has not fixed this.
And the fake engine reads SysMember.fields.user_id rather than a constant, so reverting the object file changes the fake's behaviour — that is what stops the ablation from being theatre.
The two open questions — both answered here, neither escalated
Q1 — the leak is ONE seam shared with #7725, not two. → Option A for this PR, and I am filing B separately.
You reported it instead of widening, which is exactly what ruling 4 asked for. A narrow arm keyed on DELETE_RESTRICTED is the right scope for a card with two measured codes. But the observation itself is worth keeping: the engine already declares a numeric status, and consumers are enumerating codes one incident at a time — so the next engine code needs a third incident to be discovered. Filed as an observation-class finding rather than a queue card, because nothing is broken today (#7725's symptom is resolved at the invitation layer). Triage grades it from there.
Q2 — the WebContainer AsyncLocalStorage gap. → Option A, accepted; #7880 is the right home.
Your reasoning holds: the gap is inherited equally by every transaction() caller (convertLead, publishPackageDrafts, batchData), is not introduced here, and WebContainer is a dev/demo runtime — while the defect this card fixes fires on every real attempt in production. I specifically note that you did not claim to have measured WebContainer when you had no such runtime. Declining to assert an unmeasured green is worth more than the measurement would have been.
One note for the landing check
main has moved since your base (bcea363). The queue rebuilds against current main, so nothing to do — but the landing verification on this one will check the negative space too: that rest-server.ts is still untouched after the squash, and that #7796's adoption path (adoptExistingMembership) survives alongside the new cascade, since both now decide the fate of the same sys_member row.
Activity
os-help commentedon Aug 11, 2026
PM triage (
domain:identityseat, #6022, sessionsession_01BVc1ekPpi6yaWywAUhfzfd): stayspm:queue, deliberately not dispatched yet — serialized behind #7725, which is in flight now.The two cards are one family: both are rooted in the sign-up membership reconciler (
reconcile-membership.ts) auto-binding every user to the default org, and both surface through the same bodyless-500 leak on the better-auth admin/organization routes. Dispatched in parallel they would produce two shapes for one problem onsys-member.object.tsand on the shared error mapping. #7725 owns acceptance-side adoption of the existing membership row and is explicitly forbidden from touching this card's surfaces.This card's scope when it dispatches (recorded now so it is not re-derived later):
sys_member.user_idreferential-restrict veto — the engine's own error namesdeleteBehavior:'cascade'as the fix, and a membership row without its user is meaningless, so this looks decidable by the dev rather than by the maintainer; it will be dispatched with the instruction to verify that nothing depends on the restrict (e.g. as an accidental last-admin guard) before adding the cascade.remove-userdeletes credential/session rows and does not roll them back when the engine refuses thesys_userdelete, leaving an un-authenticatable identity on the roster. This is the harmful half: it fires today, on every attempt.packages/rest/src/rest-server.tsmapsDELETE_RESTRICTEDcorrectly on the generic data route; the admin path does not). Shared with invitation-scope-gates:accept-invitation→ bodyless HTTP 500, invitation stayspendingforever (UNIQUE constraint on sys_member) #7725's symptom — fixed once, here, unless invitation-scope-gates:accept-invitation→ bodyless HTTP 500, invitation stayspendingforever (UNIQUE constraint on sys_member) #7725's dev reports it needs its own card.Also noted for whoever takes it: the operator cannot clear the blocker by hand either (
DELETE /api/v1/data/sys_member/{id}→ 405 fromenable.apiMethods = ['get','list']), so "just delete the membership first" is not an available workaround, and the break-glass guard inauth-manager.tsaddresses none of the three.Generated by Claude Code
os-help commentedon Aug 11, 2026
Input carried from the family's other half, now that #7725's PR (#7796) is reviewed and armed — read this before dispatching, it changes where the cheapest fix for problem 3 probably lives.
The bodyless-500 residue is confirmed, and it is not where the card assumed. #7725's dev traced it in better-auth
1.7.0-rc.2source rather than inferring:crud-invites.mjs'srunWithTransaction.catchsets the invitation back topending(fromStatus: 'accepted') and rethrows. So fixing the error mapping alone makes the fault visible without stopping the state from reverting — worth knowing before someone concludes the mapping fix is sufficient.packages/plugins/plugin-auth/src/objectql-adapter.tsalready haswithValidationErrorMapping, which converts engine validation errors into better-authAPIErrors. A raw driver error — the UNIQUE violation here, and by extension aDELETE_RESTRICTED409 — is not in that set, which is exactly why it escaped as a bodyless 500. WideningrethrowAsBetterAuthErrormay be cheaper and better-targeted than editingpackages/rest/src/rest-server.ts's mapping, which was this card's assumed home for problem 3. Evaluate both when it dispatches; the reserved surface stays reserved either way.What #7796 did NOT touch, so your three problems are all still yours:
sys-member.object.tsis absent from its diff (FK,deleteBehavior, indexes untouched — the unique index is honoured, not modified), and the REST mapping is absent. Confirmed against the merged file list, not just the report.One consequence of #7796 for this card's problem 1: acceptance now adopts the reconciler's row, so the "every user is already bound" invariant is now load-bearing on two paths rather than one. If you add
deleteBehavior: 'cascade'tosys_member.user_id, the cascade will also remove memberships that acceptance adopted — which is correct (a membership without its user is meaningless) but means the delete path is now the only place that decides a membership's end, with no compensating record. Worth a test that removing a user who accepted an invitation leaves nothing behind.Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Selected for dispatch this patrol, then fenced — not dispatched. Recording why so the next sweep doesn't re-derive it.
This was the highest-severity card in
pm:queueat 16:40Z and I intended to take it. It is blocked by lane serialization, not by anything on the card:deleteBehavior: 'cascade'onpackages/platform-objects/src/identity/sys-member.object.ts.confirmTextandparams, so one click opens two sequential dialogs (same shape as #7278) #7309 was dispatched intopackages/platform-objectsat 16:36Z (sys-user,sys-oauth-application,sys-two-factor,sys-account,sys-organization,sys-sso-provider,sys-team-member).sys-member.object.tsis not in #7309's file set, so the two cards touch disjoint files inside one package. Letting both run would be adopting the mutually-exclusive-region exemption, which has been raised with the maintainer and not ruled. The standing default is strict serialization, so the card waits. I am not taking the exemption on my own authority just because this card is the more serious of the two — that is precisely the pressure the default exists to resist.Cost of the fence, stated plainly so the ruling can be made on evidence: this card is a live data-integrity defect —
remove-usercan never succeed on this deployment, and it deletes credential rows without rollback, leaving an un-authenticatable identity on the org roster. It is now waiting on a card about duplicate confirmation dialogs. If the exemption were granted, this would dispatch immediately.Dispatch the moment #7309 lands (or the moment the exemption is ruled). Notes for whoever takes it:
auth-manager.tsaddresses none of them. Do not let a fix for the 500-envelope stand in for the atomicity fix; a structured 409 on an operation that can never succeed is still an operation that can never succeed.DELETE /api/v1/data/sys_member/{id}→ 405, writes refused by the identity write guard) means the operator has no manual escape today. Whether that 405 is correct is a separate question from whetherremove-usershould cascade — settle the cascade first.statusCode-leak class with invitation-scope-gates:accept-invitation→ bodyless HTTP 500, invitation stayspendingforever (UNIQUE constraint on sys_member) #7725. If the leak turns out to be one seam rather than two, that is a report — the two cards may want merging, but that is a PM call, not a scope-widening.Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
Claim: PM loop round 2 (
domain:identityseat #6022)Session:
session_01D6Qi9sYxhaRwj7TYiD5MWgBranch:
claude/issue-7724-remove-user-atomicityWorktree:
objectstack-issue-7724Domain:
domain:identityFile surface:
packages/platform-objects/src/identity/sys-member.object.ts(thedeleteBehaviordecision) ·packages/plugins/plugin-auth/src/objectql-adapter.ts+rethrowAsBetterAuthError·packages/plugins/plugin-auth/src/auth-manager.tsif the rollback lands there · their tests.packages/rest/src/rest-server.tsis in surface only if the adapter seam turns out to be the wrong home for problem 3 — report before going there.Container & model: M+ (three compounding problems + a data-integrity fix),
mode:subagent(amendment, see below),model: opusSerial constraints cleared: both fences named by the previous holder are now down. #7309 → PR #7827 landed as
098f4bb(that was thepackages/platform-objectsin-flight card this was fenced behind). #7725 → PR #7796 landed asc9c2d92.sys-member.object.tslast touched by779ace4, an unrelated docs commit — free. No other in-flight claim in this lane names any of these files.mode:cloud, but theClaude_Code_RemoteMCP server has been unreachable since ~21:30Z. Dispatching as a subagent under the standing fallback, recorded here rather than only in chat.Premise re-verified on current
origin/main, not inheritedThe card is 10 hours old and
mainhas taken ~20 merges since, four of them this lane's. Re-read by symbol:sys_member.user_idis stillField.lookup('sys_user', {…})atsys-member.object.ts:164, and nodeleteBehavioris declared — the referential-restrict veto is live.enable.apiMethods = ['get','list']still at:202— the operator still has no manual escape (problem 2 stands).{organization_id, user_id}at:192is intact — fix(plugin-auth): adopt the existing membership when accepting an invitation instead of colliding on the unique index #7796 honoured it rather than modifying it, as its PR claimed.What changed under this card while it waited
#7796 landed, and it moves where problem 3's fix probably belongs. Its dev traced the bodyless 500 in better-auth
1.7.0-rc.2source:withValidationErrorMappinginobjectql-adapter.tsconverts engine validation errors into better-authAPIErrors, but a raw driver/engine error — the UNIQUE violation there, and by extensionDELETE_RESTRICTED409 here — is not in that set. That is why it escapes bodyless. So wideningrethrowAsBetterAuthErroris likely cheaper and better-targeted than editing the REST mapping, which is what this card originally assumed. Carried into the dispatch as a PM hypothesis to measure, not as a ruling.And #7796 raised the stakes on problem 1. Acceptance now adopts the reconciler's membership row, so "every user is already bound" is load-bearing on two paths. A
deleteBehavior: 'cascade'will therefore also remove memberships that acceptance adopted — correct (a membership without its user is meaningless), but it makes the delete path the only place a membership's end is decided, with no compensating record. The dispatch requires a test for exactly that: remove a user who accepted an invitation, and assert nothing is left behind.Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
Dev died on a fleet-level account session limit — re-dispatching. The claim above stands; nothing about this card's scope changed.
Seat #6022, session
session_01D6Qi9sYxhaRwj7TYiD5MWg.What happened, and why it is not a task failure
The dispatched dev terminated with
You've hit your session limit · resets 12:10am (UTC)— an account-wide quota exhaustion, not anything about this card. Its last output was "Premise 1 confirmed by symbol. Now the auth plugin surface."Nothing survived, and that is verified rather than assumed — this matters, because the standing rule for a quota death is that the worktree keeps uncommitted work, and inherited work must then be reviewed as an untrusted draft:
So there is no inherited diff to distrust — the re-dispatch is a clean start, not a handoff. The branch was never pushed and no PR was opened.
Unchanged and still verified
The claim comment above stays valid as written. Both serialization fences remain down (#7309 →
098f4bb, #7725/#7796 →c9c2d92),sys-member.object.tsis still untouched by anything in flight, and the premise the dev had already re-confirmed by symbol before dying — theuser_idlookup with nodeleteBehavior,apiMethods: ['get','list'], the intact unique index — is the same premise the new dispatch carries.The one thing that changed is the clock:
mainhas taken further merges since the claim, so the new dev re-verifies againstorigin/mainrather than against the claim's readings.⛔ Assignee and claim untouched — this is a continuation of the existing claim, not a new one.
Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
{ "issue": 7724, "status": "done", "branch": "claude/issue-7724-remove-user-atomicity", "pr": "https://github.com/objectstack-ai/objectstack/pull/7879", "premise_still_valid": true, "summary": "All three compounding problems fixed, none standing in for another. (1) sys_member.user_id now declares deleteBehavior:'cascade' — audited first as the card required: the restrict is NOT an accidental last-admin guard, because that invariant is a beforeDelete hook registered on sys_member itself (last-admin-guard.ts:1178) and the engine's cascade recurses through the PUBLIC delete() so the hook still fires, one row deeper. (2) Non-atomicity: verified at source that better-auth 1.7.0-rc.2's internalAdapter.deleteUser deletes sessions, then accounts, then the user in three calls with the string 'transaction' absent from that file entirely; subject-erasure requests now run inside one ObjectQL.transaction opened at AuthManager.handleRequest, reusing the existing exported SESSION_ERASURE_PATHS rather than a new list. The non-obvious part: better-auth RETURNS its faults as a Response instead of throwing, so a plain try/catch unit of work would commit on exactly the path it exists to undo — rollback is therefore raised from the response status and the response carried back out through a private sentinel. (3) The PM's hypothesis about problem 3 was CORRECT: rethrowAsBetterAuthError mapped validation errors and policy refusals but not a raw engine DELETE_RESTRICTED, so it fell through to `throw err` and better-auth rendered a bodyless 500. A third arm maps it to APIError('CONFLICT') with developerMessage/dependentObject/dependentCount. packages/rest/src/rest-server.ts is UNTOUCHED. Scope held: the apiMethods/405 question was not widened (ruling 2) and the #3023 owner-anchor regression was not re-litigated (ruling 3).", "tests": "New: packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts (6 cases, real better-auth pipeline through a real AuthManager) + 3 unit cases in objectql-adapter.test.ts. The card's REQUIRED case is covered: a user who ACCEPTED an invitation (#7796 adoption path, asserted as exactly one membership before removal) is removed with 200 and leaves no sys_member, no sys_account and no sys_session. ALL THREE ABLATIONS PREDICTED IN WRITING BEFORE MUTATING, then measured. P1 drop deleteBehavior:'cascade' — predicted tests 1/2 + declaration pin red with 409 DELETE_RESTRICTED; MATCHED ('expected 409 to be 200', developerMessage naming the cascade), plus ONE UNPREDICTED RED reported honestly: the structured-body test went 'expected 409 to be 403' because with the restrict live the membership veto fires BEFORE that test's own injected refusal — a different but still structured refusal wins, not a false pin. P2 drop the DELETE_RESTRICTED arm — predicted 3 unit tests red + end-to-end bodyless 500; MATCHED exactly: isAPIError false, statusCode undefined, 'expected 500 to be 409', and 'the response body is empty — the fault leaked unmapped' (the reported symptom reproduced verbatim). P3 bypass the transaction wrapper — predicted rollback test red with credential rows still deleted; MATCHED: \"expected [] to deeply equal [ '7a34TfsZskkvSXuNqDTWwsz8KvpE6UBe' ]\", plus the 409 test's trailing 'refused AND clean' assertion, which is the card's central point. The fake engine is non-theatre: it READS SysMember.fields.user_id and reproduces the engine's own escalation arithmetic (the ablation confirmed the undeclared field reads back 'set_null', which it escalates to restrict exactly as engine.ts does), its transaction really snapshots/restores, and both write verbs are pinned to assertEngineDeleteDispatch/assertEngineUpdateDispatch. GREEN: plugin-auth 1048/1048, platform-objects 339/339, both typecheck clean, check:nul-bytes, check:docs-audit-scope, check:engine-double-contract (163 pinned — my new fake registered as pinned), check:error-code-casing, eslint --no-inline-config on all 5 changed files (no inline disables used). CONSUMER SWEEP, PREFIX DIRECTION (--filter '...@objectstack/platform-objects' --filter '...@objectstack/plugin-auth' = the 51 packages that DEPEND ON these): full build 71/71, typecheck clean across all 51, tests EXIT=0 with 19,135 passed. typecheck caught a real defect the green suite could not see: the composed scope helpers are typed Promise of Promise of Response, which the previous single call site flattened with its await. CI: in_progress at report time — not polled, per the reporting contract.", "open_questions": [ { "question": "Problem 3's leak turned out to be ONE seam shared with #7725, not two — reporting it rather than acting on it, per ruling 4. Both cards' bodyless 500s come from the same fall-through in rethrowAsBetterAuthError (objectql-adapter.ts): #7725's UNIQUE violation and this card's DELETE_RESTRICTED are both raw engine/driver errors carrying neither the validation nor the policy-refusal signature. My third arm is keyed narrowly on code === 'DELETE_RESTRICTED', so it fixes this card only and leaves #7725's class still unmapped.", "options": [ "A — leave as shipped: one narrow arm per engine code, added as each is evidenced. #7725 already landed (c9c2d92) and its own symptom is resolved at the invitation layer, so nothing is broken today.", "B — follow-up card to generalise the seam: map any engine error carrying a numeric `status` in 4xx to the matching APIError, so the next engine code does not need a third incident to be discovered." ], "recommendation": "A for this PR, and B as a separate PM-owned card if the PM wants it — merging the cards is explicitly a PM call, not mine. B is genuinely attractive (it is the contract-first shape: the engine already declares `status`, so consuming it beats enumerating codes) but it widens the blast radius to every better-auth route at once, and I have measured evidence for exactly two codes. That is not enough to justify remapping everything under one predicate in a card whose harmful half is elsewhere." }, { "question": "The transaction wrapper's atomicity depends on ObjectQL's ambient AsyncLocalStorage txStore surviving the await chain through better-auth's handler. Verified working on the real pipeline in tests; NOT verified under WebContainer, where this repo already documents that AsyncLocalStorage.run() does not propagate across await (auth-manager.ts installs a polyfill for better-auth's request-state store, but txStore has none).", "options": [ "A — ship as is and record the gap (filed as #7880, `finding`, no pm:queue).", "B — block this card until WebContainer is measured." ], "recommendation": "A. The gap is inherited by every transaction() caller equally (convertLead, publishPackageDrafts, batchData), is not introduced here, and WebContainer is a dev/demo runtime — whereas the defect this card fixes fires on every real attempt today. I deliberately did not claim to have measured WebContainer, since I have no such runtime here." } ], "out_of_scope_findings": [ "filed as #7880: ObjectQL transaction()'s ambient txStore has no WebContainer AsyncLocalStorage polyfill, so a rollback there could silently cover no writes while reporting success — observation-class, `finding` label, no pm:queue, searched for duplicates first (none).", "not filed (already recorded on this card and ruled out of scope by the PM): DELETE /api/v1/data/sys_member/{id} still answers 405 from enable.apiMethods = ['get','list'], so the operator still has no manual escape. With the cascade in place the escape is no longer needed for this defect, but the 405 question itself is untouched — deliberately, per ruling 2." ] }Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
PM review: ACCEPT — PR #7879. Seat #6022, session
session_01D6Qi9sYxhaRwj7TYiD5MWg. Pending CI convergence only; the ready-flip and queue are this seat's job, not the dev's.Verified independently, not from the report
docs/adr/**/.claude/skills/**/skills/**Fixes #7724(notPart of)packages/rest/src/rest-server.tsuntouchedsys-member.object.ts:164, with the escalation mechanism written out at the siteapiMethods/405 not widened (ruling 2), #3023 not re-litigated (ruling 3)The cascade audit is the part I most wanted and it came back non-obvious. The card required proving the restrict is not an accidental last-administrator guard before removing it. The answer is that the guard is a
beforeDeletehook onsys_memberitself, and the engine's cascade recurses through the publicdelete()— so the guard still fires, one row deeper. That is a real audit, not a reassurance: the naive reading ("cascade bypasses the child's hooks") would have been wrong in the dangerous direction.The subtlety in problem 2 is the kind that silently defeats a fix. better-auth returns its faults as a
Responserather than throwing, so a plaintry/catchtransaction wrapper would have committed on exactly the path it exists to undo — a rollback that never rolls back, green in every test that only checks the happy path. Raising rollback from the response status instead is the right shape.Both honest deviations were reported rather than smoothed. The cascade ablation produced an unpredicted red (
409→403, because the membership veto fires before that test's own injected refusal) and it was reported as an unpredicted red rather than back-filled into the prediction table. Same for the transaction ablation's trailing assertion — which is the card's thesis in one line: a correct 409 that still leaves the credential rows deleted has not fixed this.And the fake engine reads
SysMember.fields.user_idrather than a constant, so reverting the object file changes the fake's behaviour — that is what stops the ablation from being theatre.The two open questions — both answered here, neither escalated
Q1 — the leak is ONE seam shared with #7725, not two. → Option A for this PR, and I am filing B separately.
You reported it instead of widening, which is exactly what ruling 4 asked for. A narrow arm keyed on
DELETE_RESTRICTEDis the right scope for a card with two measured codes. But the observation itself is worth keeping: the engine already declares a numericstatus, and consumers are enumerating codes one incident at a time — so the next engine code needs a third incident to be discovered. Filed as an observation-classfindingrather than a queue card, because nothing is broken today (#7725's symptom is resolved at the invitation layer). Triage grades it from there.Q2 — the WebContainer
AsyncLocalStoragegap. → Option A, accepted; #7880 is the right home.Your reasoning holds: the gap is inherited equally by every
transaction()caller (convertLead,publishPackageDrafts,batchData), is not introduced here, and WebContainer is a dev/demo runtime — while the defect this card fixes fires on every real attempt in production. I specifically note that you did not claim to have measured WebContainer when you had no such runtime. Declining to assert an unmeasured green is worth more than the measurement would have been.One note for the landing check
mainhas moved since your base (bcea363). The queue rebuilds against currentmain, so nothing to do — but the landing verification on this one will check the negative space too: thatrest-server.tsis still untouched after the squash, and that #7796's adoption path (adoptExistingMembership) survives alongside the new cascade, since both now decide the fate of the samesys_memberrow.Generated by Claude Code
fix(identity): make admin remove-user atomic, cascade sys_member, and…
test(dogfood): record the #10792 member-arm exception the tightened b…
fix(plugin-auth): the better-auth-native /admin/ routes refuse an ano…