Delete means delete, and the honest answer has exceptions
Deleting a household touches seventeen surfaces in a fixed order. Here is the sequence, the parts that are metered, and what is deliberately left behind.
Engineering › What deleting an account touches
Deletion is the privacy promise nobody outside the company can check. Every product says data is removed on request, and from the outside a thorough teardown and a flag on a row look exactly the same: the account stops working, the data stops appearing, and that is the entire observable difference.
So this post is the sequence. Not because it is elegant — it is a four-hundred-line function with seventeen timed stages — but because the interesting parts are all in the ordering and the exceptions, and because the question a regulator or an app-store reviewer actually asks is not "which purges did you write" but "is it gone." The honest answer to that has exceptions, and we would rather enumerate them than round up.
A household is not one thing
Because every household has its own database, there is at least an object to destroy rather than a predicate to trust. But the database is only the largest of the surfaces:
- the household's own database, and the schema-initialisation markers keyed by *its* identifier - two object-storage prefixes — uploaded files, and the app bundles installed into that household - export archives, which are a separate problem discussed below - six categories of long-lived token index in the key-value store: calendar feed tokens, kiosk device tokens, join links, external contact tokens, AI access tokens and the app key-value store itself - rows in the shared control-plane database: memberships, the household row, the per-user inbox, the installation and feature indexes, provisioning claims - four classes of durable object - cached session and roster state for every member
The function that does this also carries a comment about itself worth reproducing, because it explains why the guard rails live where they do: *deletion reaches this function from more lanes than any list can stay current with* — the operator route, account deletion, the abandoned-household reap, a stalled-teardown resume, a retry queue, and a test-only pruning endpoint that ships in the production bundle. Guarding inside the function is what makes "no path deletes a household on legal hold" true for the lanes nobody enumerated as well as the ones we did.
Ordering matters more than completeness
The first destructive step is not destructive. It stamps a tombstone marking the household as being torn down.
From that moment the household stops being handed out by tenant resolution and its database will not be lazily re-created. That is the whole point: a teardown that dies halfway through leaves something *inert and retryable* rather than a household that resurrects itself as an empty tenant on the next member request. The stamp is written with a condition requiring that no legal hold exists, so a teardown racing a hold always loses in the safe direction.
Three ordering rules came out of getting this wrong:
**Stop billing before deleting the routing state you would need to retry it.** Obvious in hindsight, discovered in the usual way.
**Shut the usage counter's door before sweeping the store it accounts for, not after.** A write that is still allowed to reserve capacity commits its key *after* the counter returns, so a reservation that succeeds while the sweep is running re-creates a key the sweep has already walked past — and nothing ever collects it, because the reconciliation sweep only visits live households. We got this wrong twice before fixing it in the only place it could be fixed: the commit now happens inside the durable object, on the same chain the fence drains, so once the fence returns there is no store write in flight anywhere.
**Tear the counter down before deleting the household row, not after.** The counter's teardown can throw by design. Run after the membership rows were deleted, those throws left a resumed teardown reading an empty member list, so the per-member session invalidation at the end never ran for the original members — and the operation reported zero memberships removed, which looked like success.
The rule that generalises: **deletion completes only once the remaining cleanup is either done or durably owed.** Ours degrades in three tiers — do it inline, else queue it as a task, else throw and leave the tombstone as an anchor for the resume sweep. What it must never do is delete the household row while work is outstanding, because once that row is gone nothing will ever scan the household again.
The keys that outlive the account
The sharpest edge is not the database. It is the long-lived bearer tokens.
A subscribed calendar client polls an ICS URL for years. An un-unpaired wall display polls for its state every sixty seconds. Both resolve through a key-value index to a household id *before* any tenant check runs. So a surviving index does not merely fail politely — it sends that traffic into the provisioning path after the household row and its guard are gone, creating a fresh, orphaned database for a household that no longer exists, against a real quota, from an unauthenticated request.
We have a test that enumerates every key-value prefix in the codebase that is written without an expiry and requires each one to be either purged at teardown or explicitly classified. It exists because that exact thing failed three times running, on three different key prefixes, always by the same route: somebody added a key with no expiry and nobody connected it to the teardown path. The list used to be prose. Prose does not fail a build.
Deleting is metered
Here is the part we did not anticipate at all: deleting costs.
Every key-value delete is a billable operation with a rate limit, and the scheduled workload that drives teardown has a subrequest budget per invocation. Teardown deletes keys from eight different places, each proportional to something about the household — how many calendar feeds, how many paired devices, how much app storage. The median household holds a handful. A large organisation at the tier ceilings holds hundreds.
Which means a fixed per-household price is wrong for one of those two, whichever number you pick, and for a while the fan-out was *bounded on paper and unbounded in fact* — only one of the eight consumers had a limit.
So there is now one allowance, created at the top of the teardown and debited by each consumer as it goes. It makes the total a single number the budget can be told in advance, and what it actually spent is knowable afterwards. Two details that make it work:
- Exhaustion has to leave *less work than it found*, or the resumed pass repeats itself and a household one page over budget loops forever. - When the allowance is empty, the answer is stop and throw — never "do one anyway."
The things you cannot delete
Durable objects are the genuinely hard case, and the constraint is platform-level: you cannot enumerate the objects in a namespace, and merely addressing one by name instantiates it. So "delete this household's objects" is not an operation that exists. You can only name the ones you can reconstruct, and pay for each name whether or not anything was ever there.
We had a list of thirteen reconstructible rate-limit names. Pricing it honestly, that teardown cost was about forty-six per cent of the throughput of the lanes that do deletions — to reclaim two integers from buckets most households never touch. The list is now two names, and the cost is about eleven per cent. The option that looked most complete was the one that both manufactured work and lowered the number of households we can delete per night.
Of the two that stayed, one is a lease rather than a counter, and that distinction is load-bearing. Resetting a live lease *frees* it: a teardown racing an in-flight provisioning run would hand the lock to the next caller and admit a second run alongside the first — the exact thing the lease exists to prevent. So leases are released only if already expired, never reset.
And the teardown of these runs deliberately *after* the household row is deleted, because the object has no fence of its own: a request that re-checks its limit afterwards re-creates the bucket permanently. Once the row is gone nothing new can resolve the tenant, which narrows that window to requests already past authorization, at no cost.
Writing down what is left
Four things survive a household deletion on purpose:
- **Two tombstone markers**, one on the scheduler object and one on the usage counter. Delete the storage instead and the scheduler comes back as a fresh, unmarked object that arms a timer against a database that no longer exists and then reschedules its own failure forever. The scheduler's flag stops a stale alarm resurrecting itself; the counter's stops a stale write.
- **Some rate-limit buckets keyed by things we do not store** — a client address, for instance. The name cannot be reconstructed from anything we keep, and the namespace cannot be enumerated. The set is finite and frozen. The only real fix would free every live lease and clear every active lockout on the platform, so it is accepted rather than done.
- **Operational logs**, which go to the platform's log product with its own retention and no delete-by-tenant interface. This one is not fixable by a purge, so the property we enforce instead is what those records may *contain* — identifiers, never names or email addresses — and that is asserted by a test rather than by a policy.
- **Content the household did not solely own.** A member's contributions to a shared space outlive their own account. That is a product decision, not a gap.
The history of that list is the reason we trust it now. It said "three exceptions" until writing it down surfaced a fourth that was not intentional at all. The corrected sentence named two durable object classes as the exceptions; there were four classes, and it was wrong the same way again. Twice wrong in the same direction is a pattern, so the list stopped being prose. There is an inventory test in which every object class in the deployment must appear with a disposition and a reason, anything classified as cleared must be wired into the teardown, and a fifth class cannot be added without somebody deciding which it is.
Everything not on that list is asserted *absent* by a test that names no key and no table — it sweeps the stores generically, so a component that starts holding household data later is covered without anyone touching the test. Its honest limitation is that it can only find residue in a store something wrote to during the test. Its coverage is its seeds, not its sweep.
The backstop that is not code
Export archives deserve their own paragraph because they are the worst-case object: a complete, *decrypted* copy of a household's data, sitting in object storage, which the product promises to delete after seven days.
Application code enforces that, and application code is the primary control. It is not sufficient on its own, and we know that empirically: every path that clears the pointer has to remember to delete the bytes first, and that pipeline has produced three separate state-machine defects that left artifacts behind.
So the procedure also requires a storage-level lifecycle rule — delete anything under the export prefix older than eight days, abort incomplete uploads after one — as an unconditional backstop. It is the only control in this entire post that does not depend on our state machine being correct. It also cannot be expressed in the deployment configuration, which means it is a manual setting on the bucket, which means it silently disappears if the bucket is ever recreated.
That is an uncomfortable thing to depend on, and it is the most important sentence here: the control that saves you is the one that does not require your code to be right, and it is usually the one living outside your repository where nothing you own can verify it.
Read next
Written at Chickadee Bandit, a private hub for running a household — the thing all of this is load-bearing for. More engineering posts.