RFC-110: Outbound Delivery & the App↔Infra Seam¶
- Status: Draft
- Authors: Marko, Claude (Opus 4.8)
- Stakeholders: Consumer App, Server API, Infra/Delivery
- Related PRDs:
docs/prd/PRD-046-delivery-and-curation.md(product requirements)docs/prd/PRD-041-consolidation.md(the substrate this distributes)- Related RFCs:
docs/rfc/RFC-101-personal-knowledge-corpus.md(resurfacing selection reused here)docs/rfc/RFC-068-corpus-digest-api-viewer.md(the corpus-wide operator digest — explicitly NOT reused; different scope)- Related ADRs:
docs/adr/ADR-144-self-hosted-delivery-queue-outsourced-last-mile.md(queue + last-mile decision)docs/adr/ADR-145-channel-agnostic-outbox-seam.md(the seam as an architectural boundary)- Related epic: #1413 (delivery children #1412 infra, #1414 consent, #1415 assembler, #1416 seed)
Abstract¶
Specifies how the personal digest ("Your Week") and resurfacing nudges reach the user over Web
Push and email, without a request-time LLM (D6). The design draws a hard boundary — the
seam — between the app (decides what/when/who, extractive) and the infra delivery
service (decides how it's delivered: render, relay, reputation, retries, bounces). They join
only at a channel-agnostic DeliveryEnvelope written to an outbox the app owns and the
delivery worker drains. This lets the infra slice (#1412) and the app slices proceed in parallel and
join deterministically.
Problem Statement¶
PRD-041's resurfacing is computed on read and is pull-only. To become a habit (the Readwise loop) it must be pushed to the user. But delivery drags in concerns the app must not own: HTML/MJML rendering, SMTP/relay, IP reputation, VAPID signing, retries, bounce/complaint handling, unsubscribe. Folding those into the consumer API would couple product logic to deliverability infrastructure and would put a mail stack in the app's CI. We need a boundary that keeps the app extractive + testable and lets delivery be operated (and delegated) independently.
Goals¶
- A per-user digest + nudges delivered via Web Push and email on a user-controlled cadence.
- A channel-agnostic, idempotent contract between app and delivery, versioned and frozen.
- No LLM anywhere in the delivery path or its CI (D6).
- Delivery is stateless + restart-safe (dedupe on envelope id); the app's per-user store is the single source of truth.
- Suppression (bounce/complaint/unsubscribe) flows back so the app stops enqueuing dead recipients.
Constraints & Assumptions¶
- D6: the payload handed across the seam is fully pre-assembled + extractive; the delivery service renders + sends only.
- File-based per-user store: the outbox and the digest fan-out are O(users) scans; acceptable now (RFC-101 OQ-1 materialization deferred).
- Consent model (FR1 / #1414) is a prerequisite — nothing is enqueued for a user without
comms.digest.enabled(orcomms.push.enabled) and, for email,email_verified. - Deliverability is not self-hostable cheaply — the last mile goes through a reputation relay (ADR-144). Web Push has no such problem and is fully self-hosted.
Design & Implementation¶
1. The seam — DeliveryEnvelope (schema_version "1")¶
Channel-agnostic, idempotent. The app produces it; the delivery service consumes it. See ADR-145 for why this is the boundary.
Normative contract (both tracks validate against these): the machine-readable JSON Schema is
docs/api/delivery-envelope.schema.json; golden fixtures live under tests/fixtures/delivery/
(your-week-digest.v1.golden.json, resurface-nudge.v1.golden.json); the shared contract test is
tests/unit/server/test_delivery_envelope_contract.py. The JSON below is illustrative — the schema
file is the source of truth. The infra service (#1412) mirrors the same fixtures + assertions.
{
"schema_version": "1",
"id": "string", // idempotency key, app-generated; delivery dedupes on this
"user_id": "u_...",
"channel": "email" | "push",
"template": "your-week-digest.v1" | "resurface-nudge.v1",
"recipient": {
"email": "string?", // channel=email
"email_verified": true,
"push_subscription": { } // channel=push (W3C PushSubscription JSON)
},
"consent_snapshot": { // v1.1: INFORMATIONAL ONLY — the worker is stateless and
"digest_enabled": true, // must re-check CURRENT consent via /pending, not this snapshot
"cadence": "weekly" | "daily",
"unsubscribe_ref": "string" // delivery embeds this in the unsubscribe link
},
"payload": { }, // structured, channel-agnostic, GRAPH-CARRYING (see §3)
"not_before": "iso8601",
"expires_at": "iso8601", // v1.1 TTL — do not deliver after this (no stale-flush on recovery)
"created_at": "iso8601"
}
Seam v1.1 amendments (ratified 2026-08-04, post-advisor review — issues #1412/#1413). The frozen
shape and endpoints are unchanged except the additive expires_at. Ratified: (1) /status idempotent
per id; (2) /pending filters current consent, not the snapshot; (3) expires_at TTL added;
(4) failed is an always-terminal dead-letter state; (5) push 410/404 → status: "bounced"
suppression; (6) /internal/* auth is a tailnet-only shared token INTERNAL_OUTBOX_TOKEN. The
unsubscribe_ref field name (was unsubscribe_token) is the ratified spelling — infra adopts ref.
2. Outbox transport (app exposes; delivery worker polls)¶
Implemented (app side, #1415) — complete: app_outbox_store (persistence + idempotency +
consent/expiry filtering + suppression write-back), routes/internal_outbox (the two endpoints,
token-gated, mounted at /internal), app_digest_personal (the extractive assembler → email
digest + push nudges → enqueue_due_digests), the scheduler digest job kind (an operator
schedules it in viewer_operator.yaml like any sweep — fires enqueue_due_digests on cadence), and
the Web Push path: app_push_store + POST/DELETE /api/app/push/subscribe +
GET /api/app/push/vapid-key, the browser usePushSubscription composable, and the SW push handler
(public/push-sw.js). The only external inputs left are infra-owned: the VAPID keypair
(APP_VAPID_PUBLIC_KEY for the app, private half in the worker), the INTERNAL_OUTBOX_TOKEN,
and the Resend worker itself.
GET /internal/outbox/pending?channel={email|push}&limit=N→{ envelopes: DeliveryEnvelope[] }. v1.1: filters on current consent (re-readscomms), excludes past-expires_atenvelopes, and never returns a user who unsubscribed after enqueue.POST /internal/outbox/{id}/status→{ status: delivered|bounced|complaint|suppressed|failed, detail? }. v1.1: idempotent perid(a repeated terminal status is a no-op — retries after a succeeded-but-unacked send);failedis always-terminal (the worker dead-letters asfailedafter N retries); a dead push subscription (410 Gone/404) reportsstatus: "bounced"so the app suppresses it.- Internal service-to-service authentication uses a tailnet-only shared token
INTERNAL_OUTBOX_TOKEN(staged in the homelab sops-env + the app secret store; both halves must agree the name to connect). - The app writes envelopes to a per-user (or global) append-only outbox on enqueue; the delivery
worker leases + acks. Dedup + retry are keyed on
envelope.id. The app consent store is the ONLY suppression authority (no external suppression list) — dropping a second queue removes the two-lists race.
3. The payload carries the graph (moat rule)¶
payload for your-week-digest.v1 is a structured list of sections; every item is pre-resolved and
carries graph_refs (the unified canonical-KG refs, mirroring the shipped AppEntityRef:
{id: "person:… | topic:…", kind, label}) + a deep_link:
{
"sections": [
{ "kind": "revisit", "items": [
{ "quote", "episode_slug", "episode_title", "t_ms", "graph_refs":[{ "id":"person:…","kind":"person","label":"…" }], "deep_link", "source": "user"|"auto" } ] },
{ "kind": "new_in_follows", "items": [ { "episode_slug", "episode_title", "graph_refs":[…], "deep_link" } ] },
{ "kind": "trending_in_your_corpus", "items": [ { "episode_slug", "graph_refs":[{ "id":"topic:…" }], "deep_link" } ] }
]
}
The delivery service renders this to HTML/push; it never computes it. Every item MUST carry
graph_refs + deep_link (the moat rule — no flat clips), and source: "auto" marks auto-picks
(FR3) distinctly. The resurface-nudge.v1 payload is { highlight_count, lead: <digestItem> }.
4. App side — the digest assembler (#1415)¶
app_digest_personal.py: for each user due per comms.digest.cadence, assemble the §3 payload by
reusing user_episode_set() + the resurfacing due-selection + interest profile + followed-show
deltas + auto-picks. Emit a DeliveryEnvelope. Wired into the existing in-process APScheduler
(scheduler.py, today feed-sweep-only) as a per-user digest cron. Extractive only (D6).
5. Infra side — the delivery service (#1412; specified in ADR-144, revised to Resend)¶
A thin stateless worker on the homelab, tailnet-only, 443-egress-only. Drains the outbox →
renders (template → Jinja) → delivers (Web Push via VAPID; email via the Resend HTTP API) →
reports status (§2). Bounce/complaint via cursor-based polling of Resend's events API (no public
webhook — the service has no public ingress). No Listmonk/Postgres/Redis: the app outbox already is
the queue ADR-144 wanted. Web Push subscription registration is POST /api/app/push/subscribe
(app-owned endpoint, infra-owned worker).
6. Channels¶
- Web Push: fully self-hosted. Server holds a VAPID keypair, signs, delivers to browser push endpoints (FCM/Mozilla/Apple). No third party, no reputation problem. Reuses the PWA service worker.
- Email: the worker renders the §3 payload (Jinja) and sends via the Resend HTTP API
(reputation last mile; HTTPS 443, so the port-25 concern is moot). Unsubscribe uses
consent_snapshot.unsubscribe_ref.
Key Decisions¶
- The seam is a channel-agnostic outbox, not per-channel API calls — ADR-145.
- Self-host the queue, outsource the last mile — ADR-144.
- Payload is extractive + graph-carrying — the digest never generates prose (D6) and never ships a flat clip (moat rule).
- Reuse resurfacing selection, not RFC-068 — the corpus-wide digest is a different scope.
Alternatives Considered¶
- App calls a transactional email API directly (Resend/Postmark) per send. Simplest, but couples product code to a SaaS + reputation, puts a mail dependency in the app's CI, and offers no clean delegation boundary. Rejected — ADR-144.
- Per-channel handoff (app calls "send email" / "send push" endpoints). Leaks channel specifics into the app and forces the app to render. The channel-agnostic envelope keeps rendering on the infra side. Rejected — ADR-145.
- Background job materializes digests into the store, delivery reads the store. More moving parts + a per-user write amplification on a file store; the outbox-of-envelopes is lighter. Deferred until fan-out latency demands it.
Testing Strategy¶
- Unit: envelope assembly from a fixture corpus (graph refs present, deep-links valid, zero-content → no envelope); auto-pick marking; cadence/consent gating.
- Integration: outbox pending/ack round-trip; idempotent dedupe on
id; suppression write-back flips consent. No mail stack in app CI — the delivery worker is stubbed at the outbox boundary. - Infra (separate, #1412): real Resend sandbox + a Web Push end-to-end; inbox-placement smoke test.
Rollout & Monitoring¶
- Ship Web Push first (no new dep) to de-risk the loop, then email once Resend + deliverability (ADR-144) are verified.
- Opt-in + pausable; disabling the worker or pausing sends is a <5-min rollback.
- Monitor: enqueue count, delivery success/bounce/complaint rate, unsubscribe rate.
Open Questions¶
- Per-user timezone for cadence scheduling.
- Outbox storage shape on the file store (per-user append log vs a single global queue file).
- When to move from poll to push-notify between app and delivery worker.
References¶
- PRD-046; ADR-144; ADR-145; RFC-101 (§5 resurfacing); epic #1413.