ADR-145: The app↔infra delivery boundary is a channel-agnostic outbox seam¶
- Status: Accepted — 2026-08-04 (operator ratified the seam as the app↔infra boundary). App side implemented (epic #1413:
app_outbox_store,routes/internal_outbox,app_digest_personal, the schedulerdigestjob, the Web Push path). The infra delivery worker (#1412) is the remaining half. - Date: 2026-08-04
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related: RFC-110 (full schema + endpoints), ADR-144 (what runs on the infra side), PRD-046, the D6 no-request-time-LLM rule
- Tracking: epic #1413 (app slices #1414/#1415 produce it; infra slice #1412 consumes it)
Context¶
Delivery (PRD-046) splits cleanly into two responsibilities with very different concerns and lifecycles:
- App — what/when/who: assemble the digest payload (extractive, D6), enforce consent + cadence, know the user. Product logic. Must stay testable with no mail stack in its CI.
- Infra delivery — how: render to HTML/push, send via the Resend HTTP API, sign VAPID, retry, handle bounces/complaints/unsubscribe, deliverability. Operational, and delegated to a separate infra agent (#1412) working in parallel.
If these two share more than a minimal contract, they can't move independently and product code ends up owning rendering + SMTP concerns. We need a single, frozen interface so both sides build against a fixed target and join deterministically — and so this arc's app work and infra work parallelize.
Decision¶
The boundary is a channel-agnostic DeliveryEnvelope written to an outbox the app owns and the
delivery worker drains. They touch only here.
DeliveryEnvelope(schema_version "1", full schema in RFC-110 §1):{ id (idempotency key), user_id, channel, template, recipient, consent_snapshot, payload, not_before, created_at }. Thepayloadis structured, channel-agnostic, and graph-carrying — never HTML, never a flat clip.- Outbox transport (app-owned endpoints):
GET /internal/outbox/pending?channel=&limit=andPOST /internal/outbox/{id}/status. Delivery leases → delivers → acks. Dedup + retry keyed onid; the app's per-user store is the single source of truth. - Rendering lives on the infra side. The app emits the structured payload; the delivery service
maps
template→ HTML email / push notification. The app never emits HTML. - Suppression writes back across the seam: bounce/complaint/unsubscribe →
POST /internal/outbox/{id}/status+ a publicPOST /api/app/comms/unsubscribe?ref=(app-owned) that flips consent. The app stops enqueuing suppressed recipients.
Seam v1.1 amendments (ratified 2026-08-04, post-advisor review — #1412/#1413)¶
Dropping Listmonk (ADR-144 revision) makes the app consent store the ONLY suppression authority, which sharpens the contract:
/statusis idempotent perid— a repeated terminal status is a no-op (retry after a succeeded-but-unacked send)./pendingfilters current consent, not the snapshot.consent_snapshotin the envelope is informational only; the stateless worker cannot re-check, so the app must exclude a since-unsubscribed user and any past-expires_atenvelope at drain time.expires_atadded toDeliveryEnvelope(additive) — a homelab-down window must not flush stale digests on recovery.failedis always-terminal — the worker dead-letters asfailedafter N retries.- Push
410 Gone/404→status: "bounced"— a dead push subscription is the push analog of an email bounce; the app then suppresses it (so it stops enqueuing to dead subscriptions). /internal/*auth is a tailnet-only shared tokenINTERNAL_OUTBOX_TOKEN(homelab sops-env and the app secret store; both halves must agree the name to connect).
The frozen envelope shape (beyond additive expires_at) and the two endpoints are unchanged.
Why channel-agnostic (the non-obvious core). A per-channel handoff ("send this email", "send this push") would leak channel specifics into the app and force the app to render. Making the envelope channel-agnostic keeps all rendering + channel logic on the infra side; adding a future channel (SMS, in-app inbox) is an infra-only change against the same contract.
Consequences¶
Positive
- App and infra build in parallel against one frozen schema and join deterministically at the end.
- App CI has no mail/push stack — the delivery worker is stubbed at the outbox boundary; keeps CI airgapped (consistent with D6 / no-LLM-in-CI).
- Delivery is stateless + restart-safe (dedupe on
id); the app store stays the source of truth. - A new channel is an infra-side addition, not an app change.
Negative / trade-off
- One more indirection than "app calls send API directly" — an outbox + a polling worker to operate. Accepted: it buys the clean delegation boundary + parallel workstreams this arc needs.
- The schema is a frozen contract; changing it is a versioned migration (hence
schema_version).
Neutral
- Poll-based drain now (simple on the file store); can move to push-notify between app and worker later without changing the envelope (RFC-110 open question).
- Outbox storage shape (per-user append log vs global queue file) is an implementation detail under the seam, not part of the contract.
Alternatives considered¶
- App calls a transactional send API directly, per message. Simplest, but couples product code to a channel + provider, forces the app to render, and puts a mail dependency in app CI. Rejected — the whole point is a delegable, testable boundary. See ADR-144 for the build-vs-buy side.
- Per-channel handoff endpoints (
send-email/send-push). Leaks channel specifics into the app and duplicates rendering. Rejected in favour of the channel-agnostic envelope. - Shared database table as the seam instead of HTTP endpoints. Would couple both sides to the store's schema + tech; the per-user store is file-based JSON anyway. The HTTP outbox keeps the store private to the app. Rejected.
- Materialize rendered messages in the app, infra just ships bytes. Puts rendering (and its templating deps) back in the app + write-amplifies the file store. Rejected — rendering belongs with delivery.