Files
movies-diary/docs/adr/0016-account-deletion.md

5.4 KiB

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.