Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
19
docs/adr/0012-sqlite-is-the-only-backend.md
Normal file
19
docs/adr/0012-sqlite-is-the-only-backend.md
Normal 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.
|
||||
45
docs/adr/0013-config-and-bootstrap.md
Normal file
45
docs/adr/0013-config-and-bootstrap.md
Normal 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.
|
||||
56
docs/adr/0014-http-adapter-and-one-refusal-shape.md
Normal file
56
docs/adr/0014-http-adapter-and-one-refusal-shape.md
Normal 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.
|
||||
102
docs/adr/0015-transactional-outbox.md
Normal file
102
docs/adr/0015-transactional-outbox.md
Normal 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.
|
||||
103
docs/adr/0016-account-deletion.md
Normal file
103
docs/adr/0016-account-deletion.md
Normal 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.
|
||||
Reference in New Issue
Block a user