# 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..`) or inspect the stream (`nats stream view `) 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.