Context
The redundancy audit (docs/audit/dpp-engine/audit/redundancy-optimization-backlog-2026-07-20.md, Track A item #12) flagged WebhookOutbox, RegistrySyncOutbox, and SnapshotOutbox in dpp-types as duplicated, and marked it (defer). This item was scoped in detail (types, Postgres DAL, drain worker, migrations, and tests read in full for all three) before deciding not to pursue a full unification now. Filing this so the finding isn't lost.
What's shared
All three follow a "transactional outbox" shape: a Postgres table, a status enum (Pending → terminal success / terminal failure), an exponential-backoff retry policy with byte-identical SQL across all three mark_attempt_failed implementations, a counts struct for boot-time gauges, and a drain worker loop.
Why a generic trait isn't a clean fit
- Registry sync's
commit_publish writes the passport row and enqueues the registration in the same database transaction — webhook and snapshot both enqueue after-commit. This is load-bearing (a missed EU registration is a legal violation, not just a missed notification).
- Registry sync retries forever — no attempt cap, no
Exhausted state. Webhook and snapshot both cap at MAX_ATTEMPTS = 8.
- Webhook rows freeze their exact payload to replay verbatim; snapshot rows carry no payload at all and deliberately re-derive put-or-remove from the passport's current status at drain time, specifically so replays/reordering can never apply a stale action. Opposite design choices for the same-looking row.
- Different row identity (delivery
id vs. passport_id UNIQUE) and fan-out cardinality (webhook is 1-to-many, the other two are 1-to-1).
- Each has its own side-channel with no equivalent in the others: registry sync's
status_intent column (migration 0024), snapshot's enqueue_divergent repair sweep.
The one place with genuinely zero semantic difference: the back_off_or_exhaust helper is byte-identical between crates/dpp-node/src/infra/webhook_drain.rs and crates/dpp-node/src/infra/snapshot_drain.rs (~20 lines) — a safe, low-effort extraction behind a small two-method trait, whenever someone is already touching both files for another reason. Not big enough to justify its own branch on its own.
Recommendation
Leave the three implementations independent. Revisit only if a fourth outbox-shaped consumer shows up (e.g. the EU registry status-push path the registry_sync.rs docs already gesture at as a future "Phase-B" addition) — that would be real evidence for a shared abstraction, where right now there are three data points each with a justified exception.
Priority
Optional / low priority. No functional or correctness issue — purely a "could this be less code" question, and the answer turned out to be "not much, safely."
Context
The redundancy audit (
docs/audit/dpp-engine/audit/redundancy-optimization-backlog-2026-07-20.md, Track A item #12) flaggedWebhookOutbox,RegistrySyncOutbox, andSnapshotOutboxindpp-typesas duplicated, and marked it(defer). This item was scoped in detail (types, Postgres DAL, drain worker, migrations, and tests read in full for all three) before deciding not to pursue a full unification now. Filing this so the finding isn't lost.What's shared
All three follow a "transactional outbox" shape: a Postgres table, a status enum (
Pending→ terminal success / terminal failure), an exponential-backoff retry policy with byte-identical SQL across all threemark_attempt_failedimplementations, a counts struct for boot-time gauges, and a drain worker loop.Why a generic trait isn't a clean fit
commit_publishwrites the passport row and enqueues the registration in the same database transaction — webhook and snapshot both enqueue after-commit. This is load-bearing (a missed EU registration is a legal violation, not just a missed notification).Exhaustedstate. Webhook and snapshot both cap atMAX_ATTEMPTS = 8.idvs.passport_id UNIQUE) and fan-out cardinality (webhook is 1-to-many, the other two are 1-to-1).status_intentcolumn (migration0024), snapshot'senqueue_divergentrepair sweep.The one place with genuinely zero semantic difference: the
back_off_or_exhausthelper is byte-identical betweencrates/dpp-node/src/infra/webhook_drain.rsandcrates/dpp-node/src/infra/snapshot_drain.rs(~20 lines) — a safe, low-effort extraction behind a small two-method trait, whenever someone is already touching both files for another reason. Not big enough to justify its own branch on its own.Recommendation
Leave the three implementations independent. Revisit only if a fourth outbox-shaped consumer shows up (e.g. the EU registry status-push path the
registry_sync.rsdocs already gesture at as a future "Phase-B" addition) — that would be real evidence for a shared abstraction, where right now there are three data points each with a justified exception.Priority
Optional / low priority. No functional or correctness issue — purely a "could this be less code" question, and the answer turned out to be "not much, safely."