ADR 0006: Events — transactional outbox, inbox deduplication, versioned schemas

Status: Accepted

Date: 2026-07-19

Context

Integration events must survive crashes, never duplicate business effects, and evolve safely.

Decision

  • Domain events stay inside the module transaction (in-memory dispatch).
  • Integration events are written to the outbox table in the same transaction as the state change, then published asynchronously by the relay to the queue.
  • Consumers record inbox deduplication entries (event id + consumer) and are idempotent by contract.
  • Event envelopes carry id, type (module.entity.verb, semver'd), tenantId, actor, occurredAt, correlationId, causationId, version, and a payload of IDs/references — never secrets or large mutable snapshots.
  • Failed deliveries land in a dead-letter workflow with replay (apps/control → Jobs).
  • Schema changes to published events require compatibility tests (pnpm test --filter */contract).

Consequences

  • Exactly-once *business effect* with at-least-once delivery; duplicates are provably harmless (reference journey 8).

Durable worker update (2026-10-11)

The PostgreSQL relay dispatches directly to registered worker handlers; a separate queue is optional. Claims carry renewable leases and fencing tokens, and future availableAt timestamps implement durable schedules. Identity email receipts use event/consumer keys; scheduled messages and reminders use tenant-scoped delivery keys. Recurring maintenance and reconciliation use deterministic time-bucket/cursor/tenant event IDs, bounded directory pages, and independent tenant jobs.

External SMTP acceptance cannot commit atomically with a database receipt. A crash after acceptance can cause another transport attempt, so stable Message-ID headers complement transactional deduplication. Exactly-once mailbox delivery is not guaranteed. See worker delivery for the implemented jobs, encrypted deferred payloads, and authenticated feedback contract.