Skip to content

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:

  1. The same event had two incompatible definitions. event: capacity was emitted as {type, sessionId, delta, timestamp} by CapacityFeedDO.serializeEvent and as {slots, timestamp} by the polling fallback in routes/capacity.ts. The only consumer, CapacityDashboard (routed at app/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.
  2. A publish the target feed's guard rejected, silently. waitingListService sent type: "alert" to a feed whose guard requires type: "notification". The DO answered 400, the call was void stub.fetch(...), and the enclosing try/catch covered only synchronous throws — so neither the rejection nor the status was ever observed.
  3. 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.handleSSEConnect returns 503 Too many connections as an ordinary Response, so the route handed that 503 to the browser and never degraded to polling — in the one case where degrading matters most.
  4. The consumer held a third definition of the payload. NotificationSSEClient's hand-written NotificationEvent interface omitted the guardianName field that the DO, the fallback and notificationItemSchema all carried, and TVOverlay never 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 ​

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 import DurableObjectNamespace; 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.ts already makes.
  • Capacity gains a D1 read. The capacity feed reads session_capacity on 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 ​

  • AttendanceFeedDO and its route, registry entry, publish site and fallback remain in place but are marked dormant: /api/v1/attendance/feed has no client consumer. Their lifecycle is decided separately, not by this ADR.

Alternatives considered ​

AlternativeReason to reject
Extract shared helpers, keep three route handlersThe 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 directlyMakes 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 deltasA 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 publishThe 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/:streamSimplifies 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 swallowingThe 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 reviewHow 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ão guardianName), #758 (pickup authorization levels)
  • workers/src/feeds/feedRegistry.ts — the registry
  • workers/src/do/sseFanOutCore.ts, workers/src/do/sseFanOutBase.ts — the connection half, unchanged in shape
  • workers/CONTEXT.md §Real-time Event Fan-out

Distribuído sob licença MIT.