# Account deletion is a tombstone now and a purge later Deleting an account has to take effect at once (the person is gone from the instance and the fediverse) yet erase a lot: dozens of tables, images in object storage, follows on other servers, and signed `Delete` deliveries that need the account's key long after the user row would normally be gone. Doing it in one request would hold a transaction over federation and storage calls, and a crash halfway would leave a half-deleted person. ## Decision **Two phases.** 1. *Request* (`request_account_deletion`). `UserRepository::mark_deleted` runs one transaction that stamps `users.deleted_at` (the tombstone), inserts the username into `retired_usernames`, deletes the user's `refresh_sessions`, and writes a `UserDeleted` event to the outbox (ADR-0015). Migration `0102_account_deletion.sql` adds `users.deleted_at`, `users.purged_at` and `retired_usernames` (`username` primary key, `COLLATE NOCASE`, `retired_at`). 2. *Purge* (`AccountPurgeHandler`, handler name `account_purge`, runs in the worker on `UserDeleted`). **Authorization.** A user deletes themselves, an admin deletes anyone (others get 403; `DELETE /api/v1/users/{id}` takes `AuthenticatedUser` and the use case enforces the role, so there is one policy point). The proof is always the *actor's* password: the user's own, or the admin's. A wrong password is `DomainError::InvalidCredentials`: 401 with code `INVALID_CREDENTIALS` (ADR-0014), distinct from `UNAUTHORIZED` so the SPA does not mistake it for an expired session and refresh and retry. The last active admin cannot be deleted. The guard is in the `mark_deleted` `UPDATE` itself (the row is stamped only if another non-deleted admin exists), so two admins deleting each other concurrently cannot both succeed; the use case's `count_active_admins` check is only the early, friendly refusal (409). **Immediate effects, before the purge runs.** Login is refused and tokens are rejected (`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The profile and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer 410 (`tombstone_gate` for the collection endpoints, `actor_is_gone` for the actor). WebFinger answers 404 (owned by k-ap). **Purge.** Order matters and every step is idempotent: 1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's `broadcast_delete`. That only queues signed deliveries. A notifier failure is logged and never blocks the purge, and the notice is skipped once the account is purged. 2. `AccountPurger::purge`: one transaction first strips the `users` row (which also claims the purge: only a tombstoned, unpurged account matches), then deletes `ap_announces` of the user's content and by the user, the *finished* `event_queue` rows whose payload names the user, other accounts' follows of this actor (`ap_followers`/`ap_following` by actor URL), and every row in `PURGED_USER_TABLES` (including tables with no foreign key to `users`, such as `watch_events`, `wrap_up_records`, `import_*`, `blocked_actors`). `PURGED_USER_TABLES`, `URL_KEYED_USER_COLUMNS` and `PAYLOAD_KEYED_USER_COLUMNS` are checked against the schema by a guard test, so a new user-owned table cannot be forgotten. 3. Image objects (avatar, banner) are deleted after the commit, then the references are dropped (`forget_images`), so a crash in between is repaired by redelivery. **The tombstone.** The stripped `users` row keeps `id`, `username`, `role` and the `ap_local_actors` keypair (`RETAINED_USER_TABLES`), and nothing else: the email becomes the placeholder `deleted-@deleted.invalid` (which frees the real address for a new registration), the password hash becomes `!purged`, display name, bio, `also_known_as` and image paths are cleared, and `purged_at` is set. The row stays so queued `Delete` deliveries can still be signed. Broadcasts for a tombstoned author are suppressed (the handler skips them and succeeds) except the `Delete` itself. **Cleanup.** `TombstoneCleanupJob` runs daily and removes tombstones whose `purged_at` is older than `TOMBSTONE_RETENTION_DAYS` (7): all remaining `event_queue` rows naming the user (the `UserDeleted` trigger and the `Delete` deliveries), the keypair, and the `users` row. A tombstone that an unfinished (pending/processing) event still names waits for the next run; unfinished events are never deleted. **Usernames are never reused.** `retired_usernames` is permanent and survives cleanup; the registration path refuses a retired username, so a deleted name cannot be impersonated. ## Known gaps - Events already relayed to NATS JetStream are not erased; they live as long as the stream's retention allows. - A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the tombstoned user; it is removed by the cleanup. - Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep stale copies. - A handler that defers a tombstone-naming event forever delays the tombstone (and keypair) removal. No event is lost, and nothing logs this case. ## Consequences - Deletion is visible instantly and has no undo; once the purge has run the data is gone. - Federation and storage failures cannot strand an account half-deleted: the purge is redelivered and each step tolerates a re-run. - The worker owns the destructive work, so the server needs no new infrastructure beyond the outbox it already writes to.