Skip to content

feat(identity): account-delete substrate (A-1) — migration 0023 + read-time refusal chokepoints - #232

Merged
unforced merged 2 commits into
mainfrom
a1-account-delete-substrate
Jul 28, 2026
Merged

feat(identity): account-delete substrate (A-1) — migration 0023 + read-time refusal chokepoints#232
unforced merged 2 commits into
mainfrom
a1-account-delete-substrate

Conversation

@unforced

@unforced unforced commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

A-1 of the account-deletion train (24-hour undo window, ratified by Aaron). This PR is the substrate only — deliberately inert:

  • Migration 0023_account_delete.sql: nullable users.deleted_at / delete_undo_hash / delete_notice_sent_at. Claims 0023 ahead of the vault-delete train's PR-2a, which re-bases onto 0024 (sequencing recorded on Hosted door has no vault-delete path — handleAccountVaultDelete is a 501 stub (DO + R2 teardown missing) #226).
  • No route, no setter, no sweep writes these columns yet — A-3 (the DELETE /account handler) and A-4 (undo + convergence sweep) own that.
  • Its correctness proof is that the full existing suite stays green while every read path that could still act on a tombstoned account now refuses it.

Chokepoints (what each prevents)

Mirrors migration 0011's suspended_at no-oracle posture rather than inventing a second style, with one deliberate divergence: deletion is a stronger, one-way fact than suspension, so the account-bearer gates degrade all the way to the same "account not found" body a missing row gets — never the distinguishable account_suspended a live-but-suspended owner gets.

  1. requireAccount (account-api.ts) + authenticate (account-mcp-http.ts, found during the sweep — same gate shape, same risk) → 401 invalid_token, byte-identical to a missing row.
  2. findActiveSession (sessions.ts) — AND u.deleted_at IS NULL on the JOIN; the account-token mint path (account-token.test.ts) proves it end-to-end.
  3. Password login, both public submit paths (console.ts /login, oauth-authorize.ts's inline authorize-login form) — the exact wrong-password message, even on the correct password.
  4. Magic-link request — the same neutral "check your email" page, but nothing is minted or sent.
  5. Magic-link consume (code path) + the 2FA divert (handleLogin2faPost's own re-check, for the case where deletion lands between the password step and the code step) — same neutral failure, no oracle.
  6. Signup collision with a tombstoned email — verified to already degrade neutrally (no filter on getUserByEmail, so the row is still found and the existing "already exists" branch fires) — no code change needed here, just the regression test.
  7. Drip eligibility — all three eligibleFor queries (welcome, connect-nudge, feedback) gain AND u.deleted_at IS NULL.
  8. Sweep enumerations — billing (runBillingSweep), usage (runUsageRollup, newly JOINs users), and snapshot (runSnapshotSweep) sweeps all exclude a tombstoned owner.

Verification

Red-then-green, watched, not assumed: reverted the 12 chokepoint src/ files to the pre-fix commit (d773237) while keeping migration 0023 (so the columns exist, just unread by app code), ran the 10 touched test files — 14 of 15 new tests failed for the stated reason (wrong status code, session actually minted, email actually sent, sweep actually touched the vault, etc.). The 15th (signup collision) passed both before and after, as expected — it's a verification test, not new logic. Restored the fix; same 10 files now 463 pass, 0 fail.

bun run typecheck — clean, both states.

Full suite: this session's local machine hit a reproducible port-exhaustion wall (EADDRNOTAVAIL, Fallback service failed to fetch module) trying to run all 38 identity test files at once — confirmed environmental (uniform failures across files unrelated to this diff, live hub/vault stack stayed healthy at 200 throughout, elevated TIME_WAIT count, symptom varied run to run). A bun install reinstall fixed a stale-cache module-resolution issue along the way, but the full-parallel run still couldn't complete locally. The 10 touched files plus a 14-file batch of untouched ones ran clean; CI (ci.yml, a fresh runner with no shared-machine contention) is the authoritative gate here and should be watched on this PR.

Scope

Does not add: the DELETE /account route, the undo-verify endpoint, or any sweep that acts on deleted_at beyond exclusion (A-3/A-4).

rc 0.0.8-rc.127rc.130 — held at rc.130 across two rebases: main first re-bumped past it with #229 (rc.128), then again with #230 (rc.129, cloud#226 A-2 billing teardown). rc.130 is still correctly one above current main.

Rebased onto current main twice (now includes #229 cloud#226 PR-1 + #230 cloud#226 A-2); both times the only conflict was the version line — took ours (rc.130) per the workspace rule that the version line always collides and doesn't count as real overlap. The fix/test commits are unchanged in content. Re-ran typecheck + the 10 touched test files (463/463) after each rebase; live hub/vault stack confirmed healthy before and after.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB

unforced added 2 commits July 27, 2026 18:47
…-time refusal chokepoints

Adds the nullable deleted_at/delete_undo_hash/delete_notice_sent_at columns
(migration 0023, claiming the slot ahead of the vault-delete train's PR-2a —
see cloud#226) and wires the read-time refusal every account-acting surface
needs so a tombstoned row can never still act: the account bearer gate
(account-api.ts requireAccount, account-mcp-http.ts authenticate), the
session JOIN (sessions.ts findActiveSession), both password-login paths
(console.ts, oauth-authorize.ts), magic-link request + consume + the 2FA
divert (auth-handlers.ts), the onboarding drip's three eligibility queries,
and the billing/usage/snapshot sweep enumerations.

Deliberately inert: no route sets these columns yet (A-3 owns the delete
endpoint, A-4 the undo + convergence sweep). Mirrors migration 0011's
suspended_at no-oracle posture rather than inventing a second style, except
where deletion is stronger: requireAccount/authenticate answer a deleted
owner with the exact "account not found" body a missing row gets, not the
distinguishable account_suspended a suspended owner gets.

Tests to follow in a separate commit.
…e-fix commit

Adds the required coverage for the account-delete substrate (8e72fe6),
one test (or set) per chokepoint: the account bearer gate (account-api.ts
+ account-mcp-http.ts), the session JOIN (via the account-token mint
path), both password-login paths, magic-link request/consume + the 2FA
divert, the signup-collision degrade (verification only — no new code),
the drip's three eligibility queries, and the billing/usage/snapshot
sweep enumerations.

Verified red-then-green: reverted the 12 chokepoint src files (kept
migration 0023) to the pre-fix commit, ran all 10 touched test files —
14 of 15 new tests failed for the stated reason (the 15th, signup
collision, is a verification test with no new code to break, and
passed as expected both before and after). Restored the fix; same 10
files now 463/463 passing.

rc.128 -> rc.130 (re-bumped after rebasing onto main: #229 landed rc.128
first, #230 holds rc.129 ahead of this PR in the merge queue).
@unforced
unforced force-pushed the a1-account-delete-substrate branch from 5a632ef to acd7d10 Compare July 28, 2026 00:48
@unforced
unforced merged commit 4e1f405 into main Jul 28, 2026
3 checks passed
@unforced
unforced deleted the a1-account-delete-substrate branch July 28, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant