docs: adr 0016 account deletion

This commit is contained in:
2026-10-26 03:03:18 +01:00
parent bc3c9e3d23
commit 9fd1eadc95
4 changed files with 101 additions and 1 deletions

View File

@@ -0,0 +1,87 @@
# 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-<id>@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.