Skip to content

feat(identity): vault + account delete — real teardown, 24h undo, convergence sweep (cloud#226) - #241

Open
unforcedagi wants to merge 6 commits into
ParachuteComputer:mainfrom
unforcedagi:fix/vault-delete-wire-teardown
Open

feat(identity): vault + account delete — real teardown, 24h undo, convergence sweep (cloud#226)#241
unforcedagi wants to merge 6 commits into
ParachuteComputer:mainfrom
unforcedagi:fix/vault-delete-wire-teardown

Conversation

@unforcedagi

Copy link
Copy Markdown
Collaborator

Closes #226 (the hosted vault-delete door) and completes A-3/A-4 of the
account-delete train coordinated on that issue's thread (A-1 landed as #232,
A-2's billing-teardown module was already inert-on-main). Also folds in a
full adversarial review pass and pins the resulting hardening with tests.

What

Single-vault delete (DELETE /account/vaults/<name>) replaces the honest
501 stub: retype-the-name confirm + userOwnsVault gate, then the vault
worker's real POST /api/internal/destroy (DO storage + R2 prefix), then the
identity-side D1 cascade (tokens revoked, grants rewritten — not dropped, a
grants row spans every vault a client was approved for — vault/usage/snapshot
rows dropped). Order is load-bearing: storage teardown runs and is inspected
before any D1 write, so a transport error, non-2xx, or malformed body leaves
every identity row intact for retry. No undo window here, deliberately — the
verb is irreversible by construction, so the retype confirm is the guard.

Whole-account delete (A-3/A-4) wires A-1's tombstone + read-time refusals
and A-2's billing-teardown module into the ratified lifecycle: delete severs
auth immediately, erases nothing for 24 hours, then converges.

  • handleAccountDelete — admin-gated, retype-the-email confirm. Defers
    billing first (so "no new charge posts" doesn't depend on the D1 write),
    then the tombstone (conditional on deleted_at IS NULL, so concurrent
    requests can't both open a window), compensating with resumeBilling if
    the tombstone write fails. Sessions dropped + live tokens revoked as
    belt-not-braces (A-1 already refuses a tombstoned account everywhere).
    Undo token returned in the body as well as mailed, so an API client with no
    inbox can still offer undo.
  • handleAccountDeleteUndo — authenticated by the mailed token alone (a
    bearer/cookie is structurally impossible on a tombstoned account). Resumes
    billing before clearing the tombstone; every unusable token gets one
    neutral answer, no oracle.
  • runAccountDeleteSweep — past the window: teardown billing, then per owned
    vault the same destroy+D1-cleanup pair the single-vault door uses (one
    teardown path, not two), then the account rows. A vault that fails to
    destroy defers the whole account — the user row must not be purged while a
    vault it owns still exists. Rides the existing hourly drip cron.

Routes land on /account/delete, not a bare DELETE /account — that path is
the SPA's own Account-manager screen. The door descriptor deliberately does
not advertise account_delete: true: AccountCapabilities is the shared
door contract pinned from parachute-hub's door-contract, and widening it is
an upstream PR, not something this branch gets to do. Pinned in conformance
(account_delete asserted toBeUndefined) so the gap fails loudly, in the
right place, the day the contract widens.

Review-fold hardening

An adversarial pass over the wiring above surfaced these, all fixed here:

  1. BLOCKER — the sweep trusted a bare 200 from the vault DO and deleted
    the D1 rows on the strength of it, while the single-vault door validated
    the body hard; those rows are the only record of whose bytes a vault held.
    Extracted the judgement into vault-call.ts's readDestroyOutcome
    (destroyed === true plus a safe non-negative r2_objects_deleted) and
    routed both callers through it. A bad body now defers the account exactly
    like a failed destroy — a false negative costs one retry, a false
    positive would have been unrecoverable.
  2. The drip tick ran unguarded despite a comment claiming three independent
    try blocks; a permanently-throwing drip starved both sweeps forever. Now
    wrapped, and the comment is true.
  3. users.delete_undo_hash had no index, and the undo lookup is
    unauthenticated by construction — a full table scan any caller could
    drive at will. Migration 0024 adds a unique index (the lookup uses
    .first(); two rows sharing a hash would restore an arbitrary account).
  4. Hold-placed-then-both-writes-failed left cancel_at_period_end stuck
    true on a non-deleted, non-tombstoned account no sweep would revisit —
    silent subscription lapse. Now raises an operator alert.
  5. Permanent deferral was unbounded and invisible. Added
    delete_purge_attempts (migration 0024) and an operator page past 48
    passes (two days of hourly ticks).
  6. The module docstring claimed one neutral answer for invalid/unknown/
    expired tokens; expired deliberately 410s. Documented the exception.

Each fold is pinned by a mutation-verified test — two of them (the drip guard
and the stuck-hold alert) were vacuous as first written and only caught by
the mutation check; both are now controls-on-controls (see the last commit's
message for the mechanics).

Also extracts ops.ts's alert helpers into ops-alerts.ts so non-cron
surfaces can page without importing the cron router back (behavior
unchanged, ALERT_DEDUPE_MS re-exported).

Test evidence

Local at 4ce7819: identity 1160 pass (40 files, vitest under workerd) ·
vault 423 pass + 1 todo (27 files) · control plane 177 pass · both
worker typechecks clean. (Local pool ran in single-worker mode — this box
storms loopback booting 40 workerd runtimes at once; CI runs the standard
invocation.)

Known gaps referenced, not fixed here

claude added 6 commits August 11, 2026 14:43
…cloud#226)

Replaces the honest 501 stub with the door the issue asks for: the hub twin's
retype-the-name confirm + userOwnsVault gate, then the vault worker's
POST /api/internal/destroy (PR-1, ParachuteComputer#229 — DO storage + the whole vault-<name>/
R2 prefix), then the identity-side D1 cascade.

Order is load-bearing. The vault worker is the source of truth for whether
tenant data actually disappeared, so destroy runs BEFORE any D1 write and its
response is inspected, not assumed — a transport error, a non-2xx, an
unreadable body, or a malformed success shape all leave every identity row
intact for a retry (502 vault_destroy_failed). Only once storage is gone does
the D1 batch run, and if THAT fails the response says so distinctly
(500 d1_cleanup_failed) because the storage side is already erased.

vaults.ts deleteVaultD1Rows ports the hub's cascade onto D1: revoke the tokens
naming this vault, REWRITE (not drop) the grants that name it — a grants row
is keyed (user, client) and spans every vault that user approved for that
client, so dropping it over one vault would silently revoke consent on the
others — then drop the vault, usage, and snapshot rows, all in one D1 batch.

Matching is EXACT scope-segment comparison, never LIKE. The LIKE pattern is a
candidate PRE-FILTER only (cloud's registry is multi-tenant and must not be
read whole, as the hub reads its own): every true match contains the
`vault:<name>:` substring, so the pattern is a superset that can over-fetch
but never miss, and the JS check discards the extras. `xvault:foo:read` and
`account:<id>:vaults:foo:read` are exactly the strings that must survive it,
and tests pin both.

No undo window here, deliberately, and not to be confused with its sibling:
the only verb that erases vault content is irreversible by construction
(deleteAll + an R2 prefix purge), so the retype confirm IS the guard — exactly
as the self-hosted twin has it. The 24-hour window belongs to ACCOUNT deletion
(the A-train), which lands in the following commits and reuses this same
teardown pair per owned vault rather than growing a second one.

Idempotency: after a successful delete the ownership row is gone, so a second
request gets the same neutral 403 not_owner an unknown or unowned vault gets —
no existence oracle, and a re-delete never 500s.

Also: the door descriptor now advertises vault_delete: true (an unadvertised
working route ships dark), and scripts/staging-sweep.ts's header is corrected —
a real teardown verb exists now, but that script still deletes only the D1 row,
and its comment said so as if nothing else could.

Recovered verbatim from a session that died at the usage limit before its first
commit; reviewed, rebased onto main, and gated here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…A-3/A-4)

Wires A-1 (migration 0023's tombstone + read-time refusals) and A-2
(billing-teardown.ts), both of which have been deliberately inert, into the
lifecycle Aaron ratified: delete severs auth immediately, erases nothing for
24 hours, then converges.

New workers/identity/src/account-delete.ts:

  - handleAccountDelete — admin-gated, retype-the-email confirm. deferBilling
    FIRST (the reversible hold, so "no new charge posts after a delete is
    requested" does not depend on the D1 write), then the tombstone
    (conditional on deleted_at IS NULL, so concurrent requests cannot both
    open a window), COMPENSATING the hold with resumeBilling if that write
    fails. Then sessions dropped + live tokens revoked — belt, not braces:
    A-1's chokepoints already refuse a tombstoned account everywhere, so the
    account is dead the instant the tombstone commits. Then the notice email
    carrying the undo link; delete_notice_sent_at is stamped only on a real
    send. Undo token returned in the body too, so an API client with no inbox
    can still offer undo.
  - handleAccountDeleteUndo — authenticated by the mailed token ALONE (a
    bearer/cookie is structurally impossible: A-1 refuses both for a
    tombstoned account). GET (the emailed link) and POST (API). Resumes
    billing BEFORE clearing the tombstone; reports resumeBilling's
    already_canceled case plainly rather than claiming a restore it did not
    get. Every unusable token gets one neutral answer — no oracle.
  - runAccountDeleteSweep — past the window: teardownBilling, then per owned
    vault callVaultDestroy + deleteVaultD1Rows (the SAME pair the single-vault
    door drives, so there is one teardown path, not two), then the account
    rows. A vault that fails to destroy defers the WHOLE account — the user
    row must not be purged while a vault it owns still exists, since that row
    is the only thing that still says whose it was. Per-account failure
    isolation; deferral is never exposure, because the account stays
    tombstoned and A-1 keeps refusing it.

Routes land on /account/delete, NOT a bare DELETE /account: run_worker_first
matches on path not method, and the bare /account is the SPA's own
Account-manager screen (route-manifest.ts SUBTREE_ONLY_PREFIXES).

Sweep rides the existing hourly drip tick in its own try — the window is a day
long, so hourly resolution is ample and a new cron pattern would have to be
added to two wrangler.toml [triggers] blocks for nothing.

Tests to follow in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One describe per leg of the ratified lifecycle — request, window, undo,
convergence — so a regression names which promise broke.

Notable pins:
  - the stored undo secret is a HASH, asserted against sha256Hex of the
    returned token and asserted NOT to equal it;
  - the request path calls deferBilling and ONLY deferBilling (goes red if
    teardownBilling is ever pulled forward to request time), while the sweep
    is the only place cancel + delete-customer happen;
  - the severed bearer is dead on the very next call, with the exact
    "account not found" body a missing row gets — A-1's chokepoint, now
    reachable for the first time;
  - a sweep one second before expiry finds nothing due and leaves every
    artifact (the window is a promise, not a delay);
  - every unusable undo token produces ONE body, asserted by collapsing five
    of them into a Set of size 1;
  - the sweep's destroy calls are checked for the per-vault admin mint and
    audience, not merely that a fetch happened;
  - a wedged vault defers the WHOLE account and a later pass converges without
    re-destroying the vault that already went;
  - a live account is invisible to the sweep (negative control), and one
    wedged account does not block the queue;
  - the sweep is driven through handleScheduled(DRIP_CRON, …), so the suite
    cannot pass against a function no deployment calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ick doc

Tried to advertise the whole-account delete door beside vault_delete and
found the reason it can't be done here: `AccountCapabilities` is the SHARED
door contract, a `file:` dep on parachute-hub's packages/door-contract that
CI pins to a reviewed commit. Widening it is an upstream PR plus a pin bump,
not something a cloud-side wiring branch gets to do — CLAUDE.md's shared-wire-
contract rule.

So the absence is documented at the descriptor and PINNED in conformance
(account_delete toBeUndefined), which is what will fail, loudly and in the
right place, the day the contract widens and nobody lights the cloud door up.
The route works regardless; what it lacks is the advertisement.

Also records the account-delete sweep on ops.ts's hourly-tick doc, and the
mutation check behind it: replacing the runAccountDeleteSweep call in
handleScheduled's drip branch with a no-op turns 'the sweep is actually ON the
hourly cron' red (1 failed | 22 skipped), green again on restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, alerting, undo index

Adversarial review of the cloud#226 wiring. Seven code findings:

1. BLOCKER — the SWEEP believed a bare 200 from the vault DO and then deleted
   the D1 rows, while the single-vault door validated the body hard. Those
   rows are the ONLY record of whose bytes a vault held, so a 200 that is not
   the DO's real reply (renamed route, service-binding quirk, a future handler
   answering {ok:true}) would have orphaned the storage permanently: still
   billed, no longer attributable, and beyond any retry because nothing left
   would know to try. Extracted the door's judgement into vault-call.ts
   readDestroyOutcome — literal destroyed===true plus a safe non-negative
   integer r2_objects_deleted — and routed BOTH callers through it. A bad body
   now defers the account exactly like a failed destroy. The asymmetry is the
   point: a false negative costs one idempotent retry, a false positive is
   unrecoverable.

3. ops.ts ran  unguarded while the comment claimed three
   independent try blocks. A permanently-throwing drip starved BOTH sweeps
   forever — no downgrades applied, no deleted account ever purged. Wrapped;
   the comment is now true.

4. users.delete_undo_hash had no index, and the undo lookup is unauthenticated
   BY CONSTRUCTION (a tombstoned account cannot present a bearer or cookie),
   so it was a full table scan any caller could drive at will. Migration 0024
   adds a UNIQUE index — unique, not plain, because the lookup uses .first()
   and two rows sharing a hash would restore an ARBITRARY account. Same
   pattern as idx_users_drip_unsub_token (0008). 0024 was reserved for the
   vault-delete train's PR-2a, which turned out to need no columns.

5. Hold placed + tombstone failed + resumeBilling ALSO failed left
   cancel_at_period_end stuck true on a NON-deleted account with no tombstone,
   so no sweep would ever revisit it — the user's subscription silently lapses
   at the period boundary. Now raises an operator alert.

6. Permanent deferral was unbounded and invisible: billing converges on pass
   one, so a wedged vault settles into an account paying nothing, absent from
   the product, still holding data promised deleted. Added
   delete_purge_attempts (0024) and an operator page past 48 passes (two days
   of hourly ticks) — long enough for a transient fault to drain unattended,
   short enough that the unmet promise cannot quietly become a month.

8. The module docstring claimed one neutral answer for invalid/unknown/expired
   while expired deliberately gets 410. Documented the exception and why it
   leaks nothing: reaching it requires having held the real token.

Also extracts ops.ts's alert helpers into ops-alerts.ts so surfaces other than
the health check can page without importing the cron router that imports them
back. ALERT_DEDUPE_MS re-exported; behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (cloud#240)

Coverage for the folds, each verified load-bearing by mutation rather than
assumed:

  - the 200-that-isn't (finding 1/7): eight cases on the door and six on the
    sweep — destroyed:false, absent, a string; r2_objects_deleted missing,
    negative, fractional, stringly-typed; and an unparseable body. Mutating
    readDestroyOutcome to trust any 200 turns 15 tests red across the two
    files. These are the cases that would have orphaned R2 storage while
    deleting the only rows that said whose it was.
  - the guarded drip (finding 3): a live welcome-window arrival plus a
    throwing sendDrip, asserting the deleted account still gets purged on the
    same tick.
  - the stuck hold (finding 5): deferBilling succeeds, the tombstone write is
    blocked by a trigger, the release throws — asserts the operator page names
    the account, the subscription, and that the account is NOT deleted.
  - the deferral counter (finding 6): the two passes straddling the threshold
    (quiet below, page above), the alert's content, hourly dedupe, and a
    negative control that a converging account never accrues attempts.

TWO OF THESE TESTS WERE VACUOUS WHEN FIRST WRITTEN, and the mutation is what
caught it:

  - the drip test seeded the DELETED account as the drip-eligible arrival, but
    A-1 excludes a tombstoned row from every drip eligibility query, so the
    sender was never called and removing the guard passed. Now seeds a
    separate live user and asserts eligibleFor actually returns it first — a
    control on the control.
  - the stuck-hold test dropped the _mbsetupuser uni table, which requireAccount's own
    read hits first; it never reached the code under test. Now blocks exactly
    the tombstone UPDATE with a trigger.

Also retitles account-api's reclaim test (finding 2). It was called "the same
name can be created again" and certified that against a STUBBED vaultFetch —
no DO involved. Production does not have that property: idFromName maps the
reused name back to the same DO, whose in-memory destroyed latch 410s every
request while the instance stays resident. Filed as cloud#240 with probe
evidence; the test now claims only the D1 bookkeeping it actually proves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Hosted door has no vault-delete path — handleAccountVaultDelete is a 501 stub (DO + R2 teardown missing)

2 participants