code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -26,8 +26,7 @@ and each then builds only the deps it can honestly use from it.
with no server-reachable consumer — `enrich_movie`, `reindex_search`, `merge_duplicates`,
`enrich_person`, `handle_requested` — moved into a new `application::WorkerDeps`, built by
`composition::build_worker_deps` and consumed only by `crates/worker`. Every field of `Deps` is
now reachable from `presentation` except `users.delete_account` — see the dead-code exception
recorded below — and every field of `WorkerDeps` is reachable from `worker`. No field needs an
now reachable from `presentation` and every field of `WorkerDeps` is reachable from `worker`. No field needs an
`Option` or a Noop port to express "not for this binary" — which container it lives in already
says that.
- `application::WorkerServices` is the analogous split on the services side: the strict subset of
@@ -42,11 +41,6 @@ and each then builds only the deps it can honestly use from it.
sixth, `federated_profile`, is already `Option` in `Repositories` for unrelated reasons) have no
worker-side adapter. `DatabaseOutput` only carries fields both binaries can genuinely fill, so
passing it instead of `Repositories` needed no new `Option` and no fake adapter.
- Exactly one field is unreachable from its own binary, and it is called out by name rather than
folded into a group comment: `Deps.users.delete_account` (`DeleteAccountDeps`) has zero callers
anywhere in the workspace — a pre-existing dead use case, not a consequence of this split. Every
other field that used to be described as "worker-only" or "not reachable" is now reachable from
the binary that reaches it, because it lives in that binary's own container.
- Both binaries were booted under the default feature set (`sqlite`, `sqlite-federation`) to prove
this: the worker reached its steady polling state without panicking, and the server served both
a Bearer-authenticated API call and a cookie-authenticated HTML page. A container wired to the
@@ -75,3 +69,10 @@ unconditionally, with no `#[cfg(feature = "federation")]` gate. `cargo build -p
--no-default-features --features sqlite` (federation off) fails to compile because of it. This
predates worker unification — confirmed byte-identical at the commit before this plan started —
and is unrelated to the composition-root split; it needs its own fix.
## Realised by `bootstrap` (ADR-0013)
`crates/composition` is now `crates/bootstrap`, and `crates/infra-wiring` is absorbed into it. The
server and worker `main.rs` files no longer wire anything: `bootstrap::build_server` and
`bootstrap::build_worker` own the whole assembly, so the single composition root is now the only
place adapters are named. See ADR-0013.

View File

@@ -150,3 +150,7 @@ Removing `repos` now would break every one of those 40 sites for no gain this pl
it transitively via presentation/worker's default features` — pre-existing, not introduced or
fixed by this plan, and invisible to `make check`'s workspace-wide `cargo test`, which enables
the feature transitively.
---
Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend.

View File

@@ -146,3 +146,5 @@ naming the exact file and line, before reverting.
- `CONTRIBUTING.md`'s architecture rules were updated in this same commit to stop describing use cases
as taking `&AppContext` (a claim already false before this plan) and instead describe the
`&FooDeps`-struct convention this ADR documents.
Now at `crates/adapters/http-axum/src` (was `crates/presentation/src`); see [ADR-0014](0014-http-adapter-and-one-refusal-shape.md).

View File

@@ -259,3 +259,9 @@ recorded under Known follow-ups below, which is also pre-existing.
established as legitimate — test helpers build a real `Repositories` and call `composition::build_deps`.
`check-presentation-adapters` intentionally does not scan that section, and it does not need to:
`composition` is not an adapter crate.
---
Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend.
Now `make check-http-adapter-deps` over `crates/adapters/http-axum/Cargo.toml` (was `check-presentation-adapters`); see [ADR-0014](0014-http-adapter-and-one-refusal-shape.md).

View File

@@ -365,3 +365,9 @@ still fails with the same two pre-existing errors. This branch neither fixed nor
leaked into `presentation`. ADR-0008's caveat carries over unchanged: the guard assumes directory
name equals package name, true for all 28 today but not enforced.
- The deferred items in ADR-0005/0006/0007/0008 are otherwise unaffected by this plan.
---
Superseded in part by [ADR-0012](0012-sqlite-is-the-only-backend.md): the postgres backends, crates and features mentioned above no longer exist; SQLite is the only backend.
Now `make check-sqlite-is-ap-free` (was `check-social-crates-are-ap-free`); the sqlite social crates merged into `crates/adapters/sqlite`.

View File

@@ -0,0 +1,19 @@
# SQLite is the only database backend
Movies Diary supports exactly one database: SQLite. The PostgreSQL adapters and the `DATABASE_BACKEND` runtime switch are removed.
## Context
The repository carried two adapter families, `sqlite*` and `postgres*`, across five crate pairs (base repositories, social, federation, search, event queue). They were roughly 80% copy-pasted, about 6,000 lines, differing mainly in placeholder syntax (`?` vs `$1`), datetime types and FTS (FTS5 vs tsvector). Every port change had to be made twice. The postgres family had no tests, so it was never verified to work, and the owner has only ever run SQLite. It was dead weight that doubled the maintenance cost of every storage change and the number of feature combinations to build.
## Decision
Delete the `postgres*` crates and the backend switch. SQLite is the sole implementation of the storage ports. If a second backend is ever needed, it is added back deliberately, with tests, behind the existing ports rather than kept alive speculatively.
## Consequences
- One adapter family to change per port; roughly 6,000 fewer lines and five fewer crates to build and review.
- `DATABASE_BACKEND` is ignored. If it is set to anything other than `sqlite`, startup logs one warning ("DATABASE_BACKEND is ignored; SQLite is the only backend") and continues.
- Cargo features collapse to `federation` (default) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so existing deploy commands (`FEATURES=sqlite,sqlite-federation,nats`) still build. They may be removed in a future release once deploy scripts no longer pass them.
- Existing PostgreSQL deployments are not supported and have no migration path from this repository.
- ADR-0006, ADR-0008 and ADR-0009 mention postgres features and crates; they carry a note that they are superseded in part by this ADR and are otherwise left as written.

View File

@@ -0,0 +1,45 @@
# Typed configuration crate and a bootstrap crate that owns all wiring
Configuration was read ad hoc from `std::env` in many adapters, and the two binaries each carried
hundreds of lines of adapter wiring (`wire_dependencies`, `create_event_publisher`, the worker's
handler and job assembly). ADR-0005 made `composition` the single root for deps, but the binaries
still named adapters themselves, and `infra-wiring` existed only so `application` could re-export an
`AppConfig`.
## Decision
**`config` crate.** One typed `config::AppConfig` with sections (`server`, `database`, `auth`,
`events`, `nats`, `storage`, `image_conversion`, `metadata`, `federation`, `wrapup`), loaded once by
the binary via `config::load()`. Layering, lowest to highest precedence:
1. built-in defaults;
2. `config.toml`, or the file named by `CONFIG_FILE` (a named file that is missing is an error);
3. environment variables, using the legacy names (`DATABASE_URL`, `PORT`, `JWT_SECRET`, ...) with no
prefix. Every variable that ever worked keeps its name, meaning and default.
A malformed value fails startup with an error naming the variable (`PORT=abc`). An empty or
whitespace-only value is treated as unset, so the default applies (or `None` for optional fields)
rather than being an error. Adapters take their section (or a typed config built from it), never
raw environment strings.
**`bootstrap` crate.** Replaces `composition` and `infra-wiring`. It is the only crate that names
concrete adapters. It exposes `build_server(&AppConfig) -> ServerApp { router, addr }`,
`build_worker(&AppConfig) -> WorkerApp { service }`, `build_server_on(&AppConfig, SqlitePool)` (the
same router on a caller-provided pool, for tests) and `init_tracing`. Bootstrap runs
on `SqlitePool`; the event-bus backend is chosen inside bootstrap and not re-exported. `application`
keeps small application-owned `AppSettings` / `WrapUpSettings` types built from the `config`
sections, so use-case deps structs and their test fixtures
are unchanged, and `application` no longer pulls `sqlx` or `anyhow`.
**Binaries are thin.** `server` and `worker` do dotenv, `config::load`, `init_tracing`, one
`bootstrap::build_*` call and `axum::serve` / `service.run`. Their `[dependencies]` are limited to
`bootstrap`, `config`, `tokio`, `axum`, `anyhow`, `dotenvy` and `tracing`; `make
check-binaries-are-thin` enforces it. The `federation` and `nats` features forward to bootstrap.
## Consequences
- Adding or changing an adapter touches `bootstrap` only; the binaries do not change.
- The wiring is reachable from tests (`build_server_on`) without a process or a port.
- `check-federation-off-social` now inspects `crates/bootstrap/src/federation.rs`: federation-off
must still wire `LocalSocialService`, never social noops (ADR-0009).
- Operators can move settings into `config.toml` incrementally; existing `.env` files keep working.

View File

@@ -0,0 +1,56 @@
# `http-axum` is the driving adapter, and every JSON refusal has one shape
The old `presentation` crate was renamed `http-axum` and now reads as what it is: the HTTP driving
adapter. It is split into three modules by audience: `api` (JSON under `/api/v1`), `html` (the
classic server-rendered UI, with its askama templates absorbed into `html/templates`) and
`federation` (ActivityPub-facing and federation-gated routes, compiled only with the `federation`
feature, ADR-0009). `router.rs` assembles them; handlers reach use cases only through
`app_ctx.deps` (ADR-0006).
A JSON client previously met several failure bodies: `{"error": "..."}` from `ApiError`, bare
status codes with empty bodies from handlers that returned `StatusCode::NOT_FOUND`, plain text from
`domain_error_response`, axum's plain-text rejections for malformed `Json`/`Query`/`Path`, and the
rate limiter's own 429 text. The refusals were documented without a body schema.
## Decision
**One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the
SPA and existing clients read it); `code` is an additive, stable machine-readable field:
`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `INVALID_CREDENTIALS` (wrong password on a re-auth
check; unlike `UNAUTHORIZED` it must not trigger a token refresh), `FORBIDDEN`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL`.
**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON
error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that
existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401,
`Forbidden` 403, `InvalidCredentials` 401 (`INVALID_CREDENTIALS`), `InfrastructureError` 500 with the generic message `internal server error` and the
cause logged, never sent).
**Extractors.** `extractors::Body<T>`, `Params<T>` and `PathId<T>` wrap axum's `Json`, `Query` and
`Path` and turn their rejections into `refuse(.., INVALID_INPUT, ..)`. The status is whatever axum
chose for the rejection (400 for syntax errors, 415 wrong content type, 422 well-formed JSON of the
wrong shape, 413 oversize), so clients that already handled those statuses are unaffected; only the
body changed. JSON API handlers use them instead of the axum originals.
**Rate limiting.** The 429 of the `/api/v1` limiters goes through `refuse` (`RATE_LIMITED`). The
HTML and ActivityPub limiters keep axum-governor's default response.
**HTML is unchanged.** Classic handlers keep `domain_error_response` (plain status text / HTML
pages), redirects and statuses. ActivityPub-facing routes keep their status-only errors. Only JSON
responses were unified.
**Not covered.** Unmatched `/api/v1` paths (404) and wrong methods (405) are produced by the router
itself and still return axum's empty bodies.
**The spec says so.** Every operation documents at least one 4xx, every authenticated operation
documents 401, and every 4xx/5xx references `ErrorResponse`. `tests/openapi_contract_test.rs`
builds the spec from `http_axum::openapi::spec()` (no bootstrap) and fails when a new endpoint
skips this, together with a test that every 2xx JSON response declares a schema.
## Consequences
- Statuses of existing JSON endpoints are unchanged; error bodies that were empty or plain text on
`/api/v1` are now `{"error", "code"}` JSON. Bodies that were already `{"error"}` gained `code`.
- `cargo test -p http-axum` alone does not build with default features because the dev-dependency
on `bootstrap` needs its `federation` feature to match; `cargo test -p http-axum
--no-default-features --test openapi_contract_test` runs the contract test standalone, and
`cargo test --workspace` runs it with federation on.

View File

@@ -0,0 +1,102 @@
# Events go through a transactional outbox and are delivered idempotently
Domain events used to be published after the write that caused them, from the use case, on a
channel the server owned. A crash between the write and the publish lost the event (no poster, no
federation delivery, no search index entry), and with NATS the server needed a live broker
connection just to log a review. Delivery was also at-least-once without any guard, so a redelivered
event ran its handlers twice.
## Decision
**The outbox is `event_queue`.** The same SQLite table the DB-backed bus already used is now the
outbox for every backend. Migration `0101` adds `event_queue.event_id` (UUID, unique when set) and
`claimed_at`, and creates `processed_events` (`event_id`, `handler`, `status`, `started_at`,
`processed_at`). The migration backfills every existing row with a random event id, so legacy rows get dedup too.
The column stays nullable, but the only insert path (`enqueue_events`) always sets it, and the
consumer still tolerates a NULL id (no dedup) for rows from foreign writers.
**Writes are atomic with their events.** Repository and command-port methods that change state take
the events they cause and write them in the same transaction as the data. `shared::enqueue_events`
is the single path that inserts outbox rows. Covered: review log/edit/delete (including orphan
movie deletion and `ReviewDeleted`/`MovieDeleted`), watchlist add/remove, movie discovery and
enrichment requests, wrap-up, integration writes, local follow/unfollow, remote review save.
Use cases never open transactions; the adapter does.
**The server never talks to NATS.** With `EVENT_BUS_BACKEND=nats` the server still only inserts
outbox rows. The worker runs a relay that claims pending rows and publishes them to JetStream with
`Nats-Msg-Id` set to the event id, then marks the row done. With `db` the worker consumes the rows
directly. `max_deliver` on the JetStream consumer equals `EVENT_QUEUE_MAX_ATTEMPTS`, and a JetStream
nak uses the same backoff as the SQLite queue (`domain::events::retry_backoff`: 5 s doubling per
attempt, capped at 300 s), so both backends give up after the same number of deliveries on the same
schedule. They differ in what giving up means. SQLite marks the row `dead_lettered`. JetStream has no
DLQ: after `max_deliver` the message is no longer delivered but stays in the stream (until stream
limits evict it). The consumer logs `event exhausted max deliveries` at error level with the event id
on the final nak. To find such messages, watch the JetStream max-deliveries advisory
(`$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>`) or inspect the stream
(`nats stream view <stream>`) for sequences the consumer has not acked. The relay never spends
attempts on transport failures: when the bus is unreachable it defers the row with growing backoff and
pauses, so an outage cannot dead-letter rows.
**Three dedup layers.**
1. *Broker.* JetStream drops a repeated `Nats-Msg-Id` inside `duplicate_window`, set explicitly to 1
hour (longer than the relay's retry horizon) rather than the 2-minute server default.
2. *Relay lease.* A row claimed by a relay or DB consumer that then died is reclaimed once
`claimed_at` is older than 600 s, so a crash never strands an event in `processing`.
3. *Processed-event ledger.* Before a handler runs, `(event_id, handler)` is claimed in
`processed_events` (INSERT `in_progress`, or take over a claim older than the 1800 s lease).
Success marks it `done`; failure releases it; both are conditioned on the claim's millisecond
`started_at` value (`WHERE ... AND started_at = ?`), which acts as the owner token, so a stale
worker cannot clobber a newer claim. A delivery that sees a fresh claim held elsewhere is
deferred (`AckHandle::defer`: rescheduled to the lease expiry without consuming an attempt;
JetStream `nak` with delay, which does count toward `max_deliver`). A `done` row skips the handler.
Ledger rows are kept 30 days. A ledger read error skips the handler and nacks.
## Guarantees
- No two deliveries run the same handler for the same event concurrently.
- At-least-once after a crash: a lost claim expires and the event is retried.
- Handlers are idempotent, so the remaining duplicate window (crash after the effect, before the
ledger mark) is harmless. The O5 audit made each handler safe to re-run: image cleanup (NotFound
is Ok), image conversion (probes the exact key; missing original means done), follow backfill and
inbound review import (upserts, deterministic Create ids), person/movie enrichment (upserts; the
movie profile write is last because it is the "done" marker), wrap-up (a `Generating` record is
resumed; completion is atomic so `WrapUpCompleted` is emitted once), search handlers (keyed
upserts and removes), poster sync (skips when the poster is already stored under the movie id, so
no second `PosterSynced`/`ImageStored`).
## Known gaps
- **Remote and block follow events are best-effort.** `CompositeSocialAdapter` routes remote
follows and blocks through k-ap, which owns that transaction. Its events are published after the
fact through an injected publisher, so a crash between the k-ap write and the publish loses them.
Fixing it needs transaction hooks in k-ap.
- **k-ap 0.5.0 mints random ids for some activities.** Create, Announce and Like ids are
deterministic (`broadcast.rs:22-35`, `:283-288`; backfill `backfill.rs:208-212`). Delete
(`broadcast.rs:157`), Add/Undo (`:210`), Update (`:321`), Move (`:377`), the Undo wrappers for
Announce (`:81`) and Like (`:129`) and the actor update (`:339-344`) use `Uuid::new_v4()` via `url_scheme.rs:52-53` with no override; only
`broadcast_raw_to_followers` (`:395`) takes a caller id. A duplicate of those after a crash is a
fresh-id activity. They are semantically idempotent for remote servers (deleting twice, updating
to the same state), but it is a new activity, not a replay. The ledger makes this rare; closing it
means building those activities ourselves or a k-ap change.
- **Core NATS has no safety net.** `NATS_MODE=core` is fire-and-forget: no broker dedup, no
redelivery. Rows are marked done after publish; a subscriber that is down misses the event. The
ledger still prevents double handling of a repeated id.
- **`ImageStored` stays best-effort.** It is raised after an object-storage write, which no
database transaction can cover.
## Consequences
- Existing deployments keep their JetStream stream. `get_or_create_stream` applies config only on
creation, so the consumer now compares an existing stream and consumer with what this build needs
and updates `duplicate_window` (1 h) and the consumer's `max_deliver`/`ack_wait` in place, with a
warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed. Each update is a
clone of the existing config with only those fields changed, so operator-set fields and the
immutable ones (name, filter, deliver policy) are preserved. If an update is rejected the worker
logs a warning with the manual `nats stream edit` / `nats consumer edit` command and keeps
starting.
- Server and worker both migrate at startup (docker compose starts them together). `sqlite::migrate`
retries with a bounded backoff (100 ms to 1 s, 5 retries), so the loser of a migration race
re-reads `_sqlx_migrations`, skips what the winner applied and succeeds; only a persistent error
is returned.
- Handler authors must keep new handlers idempotent; the ledger narrows duplicates but cannot
remove the crash-after-effect case.

View File

@@ -0,0 +1,103 @@
# 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 user's
webhook tokens stop ingesting at once (`find_by_token_hash` ignores tokens of tombstoned users,
so the endpoint answers 401). The profile
and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer
410 (`tombstone_gate` for every `/users/{id}/...` path, content objects included, and
`actor_is_gone` for the actor). WebFinger
answers 404 (owned by k-ap).
**The service actor is not deletable.** The instance's service actor (`UserId::service_actor()`,
seeded by migration 0022, used for signed fetches) is refused by `request_account_deletion`
(403 `Forbidden`), and both UIs hide the action (`is_service_account` on the profile response;
the classic template checks the same). Deleting it would retire its name and, after the
retention period, its keypair, breaking signed fetches for good.
**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.
Global wrap-up reports (`wrap_up_records` with no user) embed `most_active_user`; the purge
sets it to null in any report naming the user and keeps the rest (`wrap_up_records.report_json`
is in `PAYLOAD_KEYED_USER_COLUMNS`).
A failed purge is redelivered and queues the `Delete` again; a repeated `Delete` is
idempotent on remote servers.
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; `purge_expired_tombstones` deletes the user's `ap_followers` and
`ap_following` rows explicitly before the `users` row (a test covers it), not by relying on
the foreign-key cascade.
- 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.