GDPR erasure that had never worked for anyone
Our account deletion referenced three tables whose DDL existed only in a code comment, so every call failed with 42P01 — after the customer's Stripe subscription had already been cancelled.
Our privacy policy said deleting your account was immediate and irreversible. The delete button existed, the confirmation flow worked, the database function was written. It had also never succeeded once, for anyone, because the function referenced three tables that did not exist.
vigil_delete_account deleted from every table holding user data — including alert_rules, team_invites and waitlist. Those three had been designed: their CREATE TABLE statements were written down. In a code comment. Nobody ever ran them against the live database, so every invocation of the function raised
42P01 relation "public.alert_rules" does not existand Postgres, correctly, rolled the entire transaction back. Every deletion attempt since the feature shipped: error, rollback, "Could not delete your data. Nothing was removed."
We verified the diagnosis against the live project rather than inferring it — called the RPC with a nil UUID, matching no rows, deleting nothing — and got exactly that error. The function could not have worked for anyone, ever. Four legal surfaces said it did.
The part that was worse than the bug
"Nothing was removed" was itself false. The deletion flow ran two irreversible operations in this order:
1. stripe.subscriptions.cancel(...) irreversible at the provider
2. rpc('vigil_delete_account', ...) fails, rolls backSo a paying customer who exercised their right to erasure got: subscription cancelled, data fully retained, account still live, and a message claiming nothing had happened. They lost the service they had paid for, in exchange for nothing.
The original ordering was not careless — its reasoning was written down and sound as far as it went: cancel first, because a live subscription attached to a deleted account keeps charging a customer who no longer has a dashboard to cancel from. What the reasoning missed is that both orderings have a bad failure, so the question is never "which order is correct" but which failure is recoverable. The order now: read the subscription state and refuse early if billing is needed but unconfigured; run the RPC — if it fails, abort with nothing external touched; cancel Stripe — if that fails, the data is gone but the login still exists, so the customer can sign in and cancel from the billing page, and retrying is safe because the RPC is idempotent; delete the login last, the point of no return, reached only after everything else succeeded. Irreversible external actions go after fallible internal ones, and the credential that lets the human recover goes very last.
Why nothing caught it for months
The unit-test blind spot: mocked tests verified the function was called with the right argument, and the mock cheerfully succeeded. The defect lived in the gap between the SQL in git and the schema in production — a gap that is invisible to any test that does not talk to a real database, because migrations here were applied by hand, and nothing connected a file in the repo to what Postgres was actually running. The same gap produced the webhook that never delivered: in both cases the code was right and the world was not, and only an end-to-end check against the real world could say so.
Three layers now stand where zero stood. The erasure path was verified against the live project — real throwaway users, real rows, real deletion, every table checked empty after — and gets re-verified after any schema change. A script diffs migration files against the applied schema, so DDL-in-a-comment can no longer masquerade as DDL-in-the-database. And the legal pages may not state the claim directly: every "immediate and irreversible" renders through a single flag, set only on the strength of the live verification, so withdrawing the claim — the day it breaks again — is one edit in one place instead of four passages someone has to remember. A test enforces that no page asserts working erasure except through the flag; the pattern is the same one that keeps every figure on the FAQ derived rather than typed. Deletion rights cut the other way too: the same clear-history capability this data model must honour is how a customer could once reset their own quota meter. While the fix was in progress, the policy said erasure was failing and gave a manual path — which cost exactly nothing, because a reader who later catches you claiming a right that errors will not believe the rest of the page either.
What to do
Run your account deletion end to end against production infrastructure this week — a real throwaway account, the real button, then verify at the database that every table is actually empty of that user, because the UI saying "deleted" is a claim about a transaction that may have rolled back. Then read your deletion code for ordering: list every irreversible external call and check what state the customer is left in if the very next step fails — anything that cancels, charges, or notifies before your own transaction has committed is this bug with a different provider's name on it. And if your migrations are applied by hand, diff the applied schema against your migration files today; every table that exists only in git is a 42P01 already scheduled.