Disciplines · Decisions (ADRs)

ADR-S3: Event, Audit, Idempotency, and Transaction Boundaries

§S0.8.b chose the transactional outbox for every mutation that crosses an external boundary (8 of 42) and a local ACID transaction (persistence + audit in one commit) for the rest.

Adopted · 2026-07-24
6sections3 minread

On this page

Status: Adopted (agent-recorded design decision) — pending named architecture ratification Date: 2026-07-24 Authors: V1 Domain Workbenches extraction audit (Phase S)

Records the event/audit/idempotency and failure semantics for the shared workbench mutation path (V1_DOMAIN_WORKBENCHES_TODOS_2026-07-23.md §S0.8). Built on the committed facts from §S0.8.a–c: 42 mutations → 34 local-ACID + 8 transactional-outbox, 0 sagas (tara-transaction-boundaries.json) and the identity contract (command / event / external-operation / correlation ids). The machine-checked model is evidence/v1-workbenches/event-audit-boundaries.json.

Context#

§S0.8.b chose the transactional outbox for every mutation that crosses an external boundary (8 of 42) and a local ACID transaction (persistence + audit in one commit) for the rest. No mutation requires a saga — none demands that an external effect be atomic with the local write. This ADR fixes the delivery, audit, and failure semantics that follow.

S0.8.d — Delivery semantics (the 8 outbox mutations)#

Dimension Rule
ordering per-aggregate (per target id) order via the outbox row sequence; no global cross-aggregate order
delivery at-least-once — the dispatcher retries a row until the external effect acks
duplication consumers are idempotent, deduplicating by externalOperationId (the outbox row id, §S0.8.c)
replay safe from the durable outbox log by externalOperationId; dedup makes re-dispatch a no-op
compatibility events are versioned; additive changes are backward-compatible; a breaking change ships a new version, never a silent reshape
poison after a bounded retry budget a row moves to a dead-letter queue (no infinite retry); it alerts and awaits manual replay

S0.8.e — Audit semantics (all 42 mutations)#

Dimension Rule
atomicity the audit row commits in the same local transaction as the persistence write (§S0.8.b local-ACID = persistence + audit); no mutation without its audit
refusal/failure capture authorization refusals and validation failures are audited too, not only successes
immutability audit rows are append-only; no UPDATE/DELETE on the ledger
redaction PII/secret fields are redacted at write time; the ledger never stores raw secrets
retention governed by a RetentionPolicy (per audit class); expiry is a scheduled purge, not an ad-hoc delete
evidence linkage each audit event links to its target id + command id (§S0.8.c) — the chain actor → command → effect

S0.8.f — Failure scenario diagrams#

The seven scenarios and the guarantee each relies on. Notation: W=workbench BFF, DB=durable store (persistence + audit + outbox, one tx), D=outbox dispatcher, X=external effect (provider/publish/blob/notification).

1. Failure before commit — atomic rollback#

sequenceDiagram participant W as Workbench participant DB as Store (tx) W->>DB: BEGIN; write + audit + outbox DB--xW: tx fails / rolls back Note over DB: nothing persisted, no outbox row, no success audit W->>DB: append FAILURE audit (refusal capture, S0.8.e)

2. After commit / before dispatch — durable outbox closes the window#

sequenceDiagram participant W as Workbench participant DB as Store (tx) participant D as Dispatcher W->>DB: COMMIT write + audit + outbox row Note over W: process crashes before dispatch D->>DB: on recovery, poll pending outbox rows DB-->>D: row (externalOperationId) D->>D: dispatch → at-least-once delivery

3. Duplicate delivery — idempotent consumer#

sequenceDiagram participant D as Dispatcher participant X as External D->>X: send (externalOperationId=k) X-->>D: (ack lost) D->>X: retry (externalOperationId=k) X->>X: dedup by k → effect applied ONCE

4. Worker loss — pending rows resume#

sequenceDiagram participant D1 as Dispatcher A participant DB as Outbox participant D2 as Dispatcher B D1->>DB: claim batch Note over D1: worker A dies mid-batch D2->>DB: claim un-acked (still pending) rows DB-->>D2: same rows → resumed, none lost

5. External success / local timeout — safe retry#

sequenceDiagram participant D as Dispatcher participant X as External D->>X: send (k) X->>X: effect succeeds X--xD: ack times out Note over D: row still looks pending D->>X: retry (k) X->>X: dedup by k → no double effect

6. Compensation failure — NOT APPLICABLE (0 sagas)#

sequenceDiagram participant W as Workbench participant DB as Store (tx) participant X as External W->>DB: COMMIT (local truth) DB->>X: eventual dispatch (outbox) Note over W,X: no mutation demands cross-boundary atomicity → sagaRequired = 0 Note over W,X: there is NO compensating action, so none can fail

7. Replay — idempotent by construction#

sequenceDiagram participant Op as Operator participant DB as Outbox log participant X as External Op->>DB: replay rows [k1..kn] DB->>X: re-dispatch (k1..kn) X->>X: dedup by externalOperationId → no double effect

Consequences#

  • The only durability seam is the after-commit / before-dispatch window (scenario 2), which the durable outbox closes. Everything downstream is made safe by dedup on externalOperationId (scenarios 3, 5, 7).
  • Because sagaRequired = 0, there is deliberately no distributed-rollback code path — a simpler, safer design than compensating transactions.
  • Named architecture ratification remains a human step; the delivery/audit model and the scenario set are coherence-gated in CI against the §S0.8.b/c evidence.

References#

  • V1_DOMAIN_WORKBENCHES_TODOS_2026-07-23.md §S0.8
  • evidence/v1-workbenches/tara-transaction-boundaries.json (§S0.8.b/c), tara-mutation-effect-matrix.json (§S0.8.a)
  • evidence/v1-workbenches/event-audit-boundaries.json (this model, machine-checked)
  • docs/adr/ADR-S1-*.md, docs/adr/ADR-S2-*.md