ADR-039: Single-Source Outbox Schema + First-Class Event-Hold

On this page

Status

Accepted (2026-07-13)

Realized by epic &71 (#1057); consumed by the finalize saga (#1047+). See the plan.

Amends

  • ADR-018 — the persistent per-service event_outbox + the OutboxDrainer. ADR-018 established one outbox table per service database (each service owns its own DB per ADR-001) and a shared drainer. This ADR (a) makes canopy-mq the single canonical source of that schema instead of 18 hand-copied migrations, and (b) adds a first-class event-hold to the outbox contract. ADRs are immutable once accepted, so this amends ADR-018 rather than editing it.

Context

Per ADR-001 every service owns its own PostgreSQL database, so the event_outbox table must be created by a migration in each service’s own migrations/ dir (sqlx runs one Migrator per service, from a compile-time path). The two outbox migrations (create + lease columns) were therefore copy-pasted byte-identically across all 18 outbox-bearing services with no shared source and no drift gate — identity was maintained purely by hand (the create migration’s own header even under-counted the copies). The shared drainer (crates/canopy-mq/src/outbox_drainer.rs) is a single hardcoded SQL string executed against every service DB via that service’s pool, so it structurally assumes a uniform outbox shape.

Separately, the epic &71 finalize saga needs to publish canopy-persons graph events transactionally with the domain write (so they are emitted iff the write commits) yet not have them delivered until the owning cross-service operation commits — and to discard them if the operation is compensated. The generic idempotency middleware cannot provide this (it is at-least-once-on-crash and caches plaintext PII), and staging events in a side table then copying them into the outbox on release would duplicate the outbox’s serialization + drain machinery. The natural home is the outbox itself.

Adding a hold column to the outbox by hand would have grown the 18× duplication. Pre-1.0 there are no deployments, so the schema can be freely restructured — fix the duplication first, then add the hold once.

Decision

1. canopy-mq owns the canonical outbox schema

The canonical outbox migrations live once, in crates/canopy-mq/outbox-migrations/. cargo xtask outbox-migrations --write generates byte-identical copies into every outbox-bearing service’s migrations/ dir; cargo xtask outbox-migrations --check (the default; run in the pre-push battery via cargo xtask validate) fails on any drift. Target services are discovered (any services/<svc>/migrations/ containing the canonical create migration), so canopy-portal (Postgres-free, ADR-026) is skipped and a new outbox-bearing service is picked up automatically. Each service keeps its own single sqlx::migrate! Migrator — no dual-Migrator, no ignore_missing, no test-harness change; the single-source guarantee is the canonical dir + the parity gate, not a runtime mechanism.

2. First-class event-hold

The outbox gains two nullable columns, hold_operation_id UUID + hold_generation INT (a partial index covers currently-held unpublished rows). A producer stages a held event with Publisher::publish_tx_held(&mut tx, envelope, EventHold { operation_id, generation }) — written atomically with the domain write, exactly like publish_tx, but carrying the hold key. The drainer’s claim gains AND hold_operation_id IS NULL, so a held row is never delivered. release_held(exec, hold) clears the key (the row then drains normally); drop_held(exec, hold) deletes still-held unpublished rows (compensation). Both are idempotent and run on any executor (the caller’s transaction or pool). The (operation_id, generation) granularity lets a re-attempted operation release/drop exactly its own events.

The predicate is a no-op for every existing caller: publish_tx writes NULL hold columns, so the drainer sees every non-held row exactly as before. The generation field is deliberately generic (an opaque attempt counter), not finalize-specific, so any future staged-release use can adopt it.

Consequences

  • One canonical schema definition + a CI parity gate; the 18 per-service copies are generated, never hand-edited. Adding an outbox migration is one edit to the canonical dir + --write.

  • event_outbox everywhere gains two always-NULL-by-default hold columns (additive; ADR-016 forward-only). Because there are no deployments, the schema was restructured freely; this ADR is not back-compatible with a hypothetical already-deployed outbox and does not need to be.

  • New canopy-mq API: EventHold, Publisher::publish_tx_held, release_held, drop_held. `publish_tx’s behavior is unchanged (it now writes NULL hold columns via the shared staging path).

  • The finalize saga (epic &71) stages persons graph events held, releases them after the application commits, and drops them on compensation — so downstream never observes a partial or aborted finalize (ADR-038 §6).

Alternatives considered

  • Add the hold columns to each service’s outbox by hand (the pre-existing pattern). Rejected — grows an already-fragile 18× copy-paste with no drift gate for a capability only one service uses today.

  • A canopy-mq-owned separate Migrator that bootstrap runs against each service DB. Rejected — two Migrators sharing one _sqlx_migrations table trips sqlx’s VersionMissing check unless both set ignore_missing, which permanently weakens the "a migration was removed" safety net for every service, and it would require every service’s ephemeral-schema test harness to also run canopy-mq’s migrations. The generator + parity gate achieves single-source with none of that.

  • A persons-local finalize_held_events staging table, promoted into the outbox on release. Rejected — it duplicates the outbox’s serialization + drain semantics in a service-local table and leaves the pre-existing outbox-schema duplication unaddressed; the hold belongs in the outbox contract, where it is reusable.

  • Overloading published_at with a sentinel to "hide" held rows. Rejected — a fig leaf: published_at means "already delivered", and overloading it corrupts the drain/janitor semantics.

Amendment (2026-07-16) — Abandoned-hold ownership: mq exposes the signal, never reaps (#1061)

The original decision left one lifecycle question open: what happens to a held row whose owning operation never calls release_held/drop_held? The drainer’s claim predicate skips held rows and the ADR-018 janitor deletes only published rows, so an abandoned hold accumulates forever, silently. The failure mode is real, not hypothetical: a coordinator crash before the applications-side finalize_operations record exists leaves held rows that the ADR-038 reconciler can never see (it walks finalize_operations, not event_outbox), and the steward orphan sweep deliberately refuses receipt-covered graphs. The devstack accumulated 36 such rows in canopy_persons.event_outbox during pre-reconciler crash testing.

Decision. The mq layer exposes the signal and never reaps. A third drainer-owned task (the held-row watch) polls the held set every 5 minutes, exports canopy_mq_outbox_held_rows and canopy_mq_outbox_held_oldest_age_seconds gauges (behind the otel feature; the OTLP resource carries service.name), and emits a tracing warn — count, oldest age, oldest hold_operation_id; ids and counts only, no payload — once the oldest hold outlives CANOPY_MQ_HELD_AGE_WARN_SECS (default 7200, twice the reconciler’s default stuck-op grace).

Why no mq-level TTL reaper. The hold key is deliberately opaque to canopy-mq (an (operation_id, generation) pair with no semantics attached), so the mq layer cannot distinguish a live-but-slow saga’s hold from an abandoned one. Auto-dropping on age would delete a slow-but-live finalize’s events (violating the ADR-038 §6 atomic-visibility guarantee from the Consequences above); auto-releasing would publish a partial graph — strictly worse. Semantic release/drop therefore stays with the hold’s creator side: the finalize saga post-commit, its reconciler’s retry/compensate paths, and — for holds with no surviving operation record — a steward-driven remediation informed by exactly this signal (the warn names the orphaned operation id). If a recurring no-op-record crash class emerges, the fix belongs in the coordinator’s ordering (persist the op record before staging holds), not in an mq guess.

Edit this page · default