Status: Shipped. future-features.md item 8 (“OutboundEvent, a durable outbox, pending → claimed → delivered | failed, idempotent delivery ids”) was the last named durability gap left open by the saga-durability review (docs/decisions/ has no ADR for that review; its findings live in saga_pending_dispatch.rb’s header and the guide’s “Nobody had to notice” caveat). This ADR closes it — for the Ruby runtime, on Memory, Sqlite, Postgres and PostgresEra — and records what it deliberately does not do.
A dispatch was three separately-committed things: the aggregate save (step_save, inside the adapter’s own transaction where it has one), the event record (step_emit → record_event, a second statement after that transaction closed), and the reactions (Dispatcher#dispatch’s two announced.each loops — policies, then sagas — after the interpreter returned and the aggregate lock was released). Nothing tied them together. A process that died after the save and before the reactions lost every reaction silently: the store said the command happened, nothing said what was still owed because it happened. Sagas had a partial answer (SAGA_PENDING_DISPATCH_KEY, visibility of a stalled leg, no redrive); policies and the external effects they trigger through outbound port operations had none.
One row per (event, consumer), committed with the save. Runtime::Outbox::Fanout.rows_for resolves, at enqueue time, exactly the consumers PolicyInterpreter#policies_for and SagaInterpreter#advance would select at delivery time — every matching policy across every loaded bluebook, every process manager of the emitting domain that starts on, ends on, or handles the event — and writes one Outbox::Row each: delivery_id (<event uid>/<consumer>, UNIQUE per store), the serialized event, kind (reaction, or effect when the policy’s trigger resolves to an outbound asks port operation), status.
One commit boundary. Interpreting#run_dispatch_order now splits the vocabulary’s dispatch order at save and runs save, emit, and the enqueue inside repository.transaction { }. No step was added — AggregateDispatchOrder/EntityDispatchOrder are a pinned contract (spec/vocabulary_conformance_spec.rb); the enqueue is a consequence of emit, not a step. AppendOnly#transaction forwards to the adapter’s own when it has one (Sqlite, Postgres, PostgresEra — each made re-entrant so their existing atomic_put/save/append transactions join the outer one instead of raising “BEGIN inside BEGIN”) and just yields otherwise. A side effect worth naming: Sqlite’s plain save (append + project, previously two un-transacted statements) is now atomic too.
Inline delivery, unchanged order. Dispatcher#react drains the rows the interpreter handed back: for each, outbox_claim (a compare-and-set UPDATE … WHERE status = 'pending'), run the consumer through the same PolicyInterpreter#react/SagaInterpreter#advance as before (each grew an only: to run one named consumer), outbox_settle to delivered — or failed, with the defect, when something escapes the interpreters’ own refusal/defect handling (a consumer no bluebook declares any more, say). Policy rows before saga rows, event order within each: the order dispatch always ran reactions in, which is why no existing reaction/saga spec changed. Port operations, which save nothing, enqueue after the fact through the same relay — durable, not transactional, because there is nothing for them to be transactional with.
Boot-time reconciliation. Loader.boot calls Relay#redrive! once the dispatcher exists (a row’s consumer runs through its interpreters, so this cannot be a plain registry gate like saga rehydration) and after sagas are rehydrated (a redriven row may advance one). pending rows are redriven — their consumer provably never started, claiming being the first thing delivery does, so this is exactly-once by construction. claimed rows are surfaced (warn, plus a stalled: true entry in Relay#log) and left standing for redrive!(claimed: true), an explicit operator call.
Adapters. Memory (in-process rows, so a spec can watch them move — gone with the process like everything else Memory holds), Sqlite (hecks_outbox table in the aggregate’s own database file), Postgres and PostgresEra (shared Adapters::PostgresOutbox module — the outbox is not lineage-specific, so it is copied between them exactly the way their events/hecks_saga_instances DDL already is). The contract is four optional methods probed together by AppendOnly#outbox? — outbox_enqueue, outbox_claim, outbox_settle, outbox_rows — plus transaction. Heki, LocalStorage, D1, Lambda and Folder have none; a domain bound to one of them that declares a policy or process manager gets the pre-outbox behaviour (reactions run directly) and a boot warning from Registry::Verification#warn_undurable_outbox!, a warning rather than a refusal for the reason warn_undurable_sagas! already gives.
Inspection. runtime.outbox.rows, .rows(status: "claimed"), .redrive!, .redrive!(claimed: true), .log. The log is a new, Ruby-only array — never a key in saga_log/reaction_log, which the Rust kernel ports byte-for-byte (registry.rb’s own note on saga_dispatch_log).
claimed row. The consumer started and the crash hid its outcome. Redelivering it blindly is how a stalled transfer becomes a double-credited one — the reasoning saga_pending_dispatch.rb already gives, unchanged. A claimed row is the durable, crash-surviving record of “this ran, outcome unknown”; deciding it is safe to run again is a human’s call, made with redrive!(claimed: true).delivery_id makes recording idempotent (the same fact to the same consumer is one row, ever). It does not make a policy’s trigger command recognise a duplicate; that remains the target command’s own given to write, the “two layers” doctrine in docs/event-storming-policies.md.pending rows is exactly what redrive! already is, run continuously — a bin/outbox and a lease/timeout on claimed are the next slice, not this one.save_saga before it. reaction_log/saga_log are byte-identical to before, so the differential specs are unaffected.SAGA_PENDING_DISPATCH_KEY. A saga leg’s own dispatch is still checkpointed-then-dispatched inside settle_transition; the marker still tells a rehydrating boot that a leg was mid-flight. The outbox adds the triggering event’s row for that saga (saga:<Domain>::<PM>), so the two together now say both “this event was owed to this saga” and “this saga was mid-leg” — complementary, not redundant.spec/runtime/outbox_spec.rb (Memory and Sqlite): one delivered row per consumer, no row for an unheard event, duplicate enqueue is a no-op, a defect marks the row failed with the reason, dry runs enqueue nothing; on Sqlite — a failing record_event rolls back the save AND the rows, a crash (a non-StandardError, the way saga_crash_recovery_spec.rb already simulates one) between commit and delivery leaves a pending row that the next boot redrives, a crash inside the consumer leaves a claimed row that the next boot warns about and only redrive!(claimed: true) runs; verify! warns for Heki and stays quiet for Memory/Sqlite.spec/runtime/outbox_postgres_spec.rb (io): the same four crash/rollback cases against both Postgres and PostgresEra, green against a local Postgres.io suites (68 examples) unchanged; bin/doc_coverage green (no new DSL word — the outbox is runtime behaviour, not vocabulary).