ADR-0043: One Definition Per Feed — the fan-out seam #604/#609 left open
Status: accepted Date: 2026-09-09 Deciders: @barateza + grilling session Amends: ADR-0032 Tags: [sse, real-time, durable-objects, capacity, notifications, attendance, adr-0032]
Context
ADR-0032 replaced D1 polling with three Durable Objects (CapacityFeedDO, NotificationFeedDO, AttendanceFeedDO) and claimed that "all three event streams follow the same architecture". That claim is now half-true, and the half that is false has produced four defects.
Issues #604 and #609 unified the connection half correctly: SSEFanOutCore owns the client registry, broadcast and keepalive, SSEFanOutBase owns the DO runtime wiring (TransformStream, alarm, stale-sweep), and each feed subclass supplies only a payload guard, an SSE event name and a serializer. That is a deep module and the pattern to follow.
The delivery and publish halves were left copied. A survey of the fan-out at main (2026-09-09, fde2c81) found:
- The same event had two incompatible definitions.
event: capacitywas emitted as{type, sessionId, delta, timestamp}byCapacityFeedDO.serializeEventand as{slots, timestamp}by the polling fallback inroutes/capacity.ts. The only consumer,CapacityDashboard(routed atapp/src/app/routes.tsx:167), declares the second shape — so on the normal path (DO reachable) it received a payload it could not read. The DO also sent only a keepalive on connect, so a cold client was never seeded. - A publish the target feed's guard rejected, silently.
waitingListServicesenttype: "alert"to a feed whose guard requirestype: "notification". The DO answered400, the call wasvoid stub.fetch(...), and the enclosingtry/catchcovered only synchronous throws — so neither the rejection nor the status was ever observed. - The polling fallback was unreachable for every non-throwing DO failure. All three route handlers used
try { return await stub.fetch(doRequest) } catch { /* fall back */ }.SSEFanOutBase.handleSSEConnectreturns503 Too many connectionsas an ordinaryResponse, so the route handed that 503 to the browser and never degraded to polling — in the one case where degrading matters most. - The consumer held a third definition of the payload.
NotificationSSEClient's hand-writtenNotificationEventinterface omitted theguardianNamefield that the DO, the fallback andnotificationItemSchemaall carried, andTVOverlaynever rendered it — so the telão (issue #727) could not show the guardian name it was built to show.
Underneath these: six sites resolved a DO stub by hand (idFromName → get → fetch on one of four placeholder URL spellings), four different failure policies, and three DO key derivations ("global", churchId, "default").
The common cause is not untidiness. It is that an event's meaning existed in more than one place, so nothing could check the definitions against each other.
Decision
A feed's event has exactly one definition. A feed registry entry declares, per stream: the SSE event name, the route path, the DO binding, the DO key derivation, the publish payload guard, the wire-payload builder, and the fallback polling source. Both sides of the seam read from it — the DO (connection) and the route (delivery, including its polling fallback). The wire-payload builder is the only place a feed's payload shape is expressed.
Each feed declares whether a publish carries data or a signal. A signal publish carries no payload and the feed builds the wire payload itself, reading D1 where needed; a data publish carries the payload, because there the event is the payload. Capacidade is a signal (its consumers need current occupancy, not a delta they cannot apply to a slot they have not been sent); notificações is data. This is consistent with ADR-0032's constraint that "the DO only reads event metadata to push to clients" — reading is permitted, writing is not.
The polling fallback triggers on a thrown fetch or a non-2xx DO response. ADR-0032 stated that "if the DO is unreachable, clients fall back to polling"; it is hereby made true rather than aspirational.
A publish never fails the domain write, but a rejected publish is logged as a defect. The DO is a push layer, never the source of truth, and the fallback exists so a missed push degrades gracefully instead of failing a check-in. However a non-2xx response from a feed is an application defect, not noise: it must be logged with the stream, the key and the status. Discarding a response without reading its status is prohibited at the seam.
A feed with no client consumer is marked dormant in the registry, with the reason and the tracking reference. An unconsumed feed is an untested contract; the marker keeps the fact in code, where the next feed's author will look, instead of in a ticket.
Client consumers parse the wire payload with the shared Zod schema. A hand-written payload interface at the consumer is a second definition and is not permitted — it cannot be compared against the feed at runtime, which is why the divergence in (4) was undetectable.
Consequences
Positive
- Locality. The rules that F1–F4 violated are expressed once: a feed's payload shape, its guard, its key, and what counts as a delivery or publish failure. A fourth stream must declare each of them, so it cannot inherit the old pattern by accident.
- Leverage. Producers call
publish(env, stream, key, payload)and no longer importDurableObjectNamespace; routes are built from a registry entry instead of reimplementing proxy → fallback → serialize. - Testability. The defects become assertable invariants rather than review findings: wire-payload identity across the DO and fallback paths, guard satisfaction at every publish site, every publish site naming a registered feed, every feed consumed-or-dormant, and consumer-payload parity against the wire schema.
- Honest failure. A rejected publish is visible, so the F2 class (a publish the target feed refuses) surfaces immediately instead of being swallowed for months.
Negative
- One more indirection. A reader tracing a feed now starts at the registry rather than at the DO class. Mitigated by the registry entry being the smallest possible expression of the feed.
- The registry is a hot file. All three streams are described in one place, so it will be touched by most feed changes. This is deliberate: co-location is what makes divergence impossible, and it is the same trade-off
workers/src/routes/registry.tsalready makes. - Capacity gains a D1 read. The capacity feed reads
session_capacityon connect and on each notify. Against the free-tier budget this is negligible (D1 allows 5M rows read/day), and it trades a read for removing a whole class of cold-client bug. The binding free-tier limit for feeds is DO duration, which this decision does not change.
Neutral
AttendanceFeedDOand its route, registry entry, publish site and fallback remain in place but are marked dormant:/api/v1/attendance/feedhas no client consumer. Their lifecycle is decided separately, not by this ADR.
Alternatives considered
| Alternative | Reason to reject |
|---|---|
| Extract shared helpers, keep three route handlers | The payload mapping and the proxy decision stay written three times, so F1 can recur in a fourth feed. The duplication removed is the loop, not the definition. |
Route fallback imports the DO's feedConfig directly | Makes the DO the definition without unifying the binding and key derivation, and forces the route layer to know DO internals. |
| Carry data on every publish, fallback reconstructs deltas | A delta cannot seed a cold client and cannot be applied to a slot the client has not been sent; the fallback would have to reimplement a diff mirroring the producer's. |
| Signal on every publish | The notification payload is the event; a signal would force the feed to re-read a row it was just handed. |
Collapse the three routes into one generic /api/v1/feeds/:stream | Simplifies the surface but changes three documented public routes and their registry/OpenAPI entries for no gain in the definition problem being solved. |
| Propagate publish failures for check-in instead of swallowing | The polling fallback exists precisely so a missed push is recoverable; propagating buys noise rather than safety, and would fail check-ins when a TV screen is unreachable. |
| Keep the current shape; rely on code review | How F1–F4 arose. Four defects survived review because no invariant made them checkable. |
References
- ADR-0032 (amended): Durable Objects for Real-Time Event Fan-Out
- ADR-0023: Server-Authoritative Model — Command Handler Único + Events como Auditoria
- Issue #380 (epic), #604 (
SSEFanOutBase), #609 (AttendanceFeedDO), #602 (single check-in write sequence), #727 (telãoguardianName), #758 (pickup authorization levels) workers/src/feeds/feedRegistry.ts— the registryworkers/src/do/sseFanOutCore.ts,workers/src/do/sseFanOutBase.ts— the connection half, unchanged in shapeworkers/CONTEXT.md§Real-time Event Fan-out