agenthropic

ADR-0007: CD-5 — Transport is SSE with same-origin enforcement

As-built update — 2026-07-30

Verdict: holds, built as decided. SSE is the transport; no WebSocket dependency exists anywhere in the tree.

One acceptance item is not built as written: WP-U1 calls the endpoint “resumable.” The server emits a retry: directive so a dropped client reconnects, but there is no Last-Event-ID replay — a reconnecting client resubscribes to the live feed rather than being caught up on frames it missed. For a liveness channel whose durable facts all live in the database this is a small gap, but it is a gap, not a completed criterion.

As-built update — 2026-08-15

Verdict: holds, with one word corrected. SSE is still the only realtime transport in the tree, no WebSocket dependency has appeared, and the security gate would fail the build if one did (ADR-0009). The negative catalogue still asserts a 403 on a foreign Origin with and without a valid token, and byte-identical 401 bodies across the four wrong-token shapes, so neither check leaks an oracle.

Calling those assertions merge-blocking was wrong when written and is now only partly right. They run in CI on every push and pull request and fail the run; since 2026-08-25 main is branch-protected on the ci check, so that failure withholds a merge from a contributor, while the owner stays exempt by design (enforce_admins: false) — see the standing correction.

The Last-Event-ID gap is unchanged: the server still emits only a retry: directive, a reconnecting client still resubscribes to the live feed instead of being caught up on the frames it missed, and WP-U1’s “resumable” is still not met. It has been open long enough now to be worth naming as a decision rather than an oversight — nobody has chosen to build replay, and nobody has chosen to drop the word from the work package either.

Context

The dashboard needs a live, server→browser feed so the status board and DAG views update without polling. docs/ai/DESIGN.md §3’s original architecture diagram names the transport loosely as “WebSocket/SSE,” leaving the choice open. A bidirectional channel (WebSocket) carries a larger same-origin/attack-surface burden than a strictly one-directional one, and this project’s security posture treats attack-surface minimization as a first-class constraint (docs/ai/DESIGN.md §8), not an afterthought.

Decision

Transport is Server-Sent Events (SSE), with same-origin enforcement live from Phase 1. The feed is server→browser-only; WebSocket is revisited only if a genuine bidirectional control need ever arises — it does not today. This settles the transport choice docs/ai/DESIGN.md §3 left open in favor of SSE specifically.

Acceptance criteria

From concept-analysis-v2.md §6 (“Security, build-failing, from Phase 1”) — the CD-5/CD-7 shared acceptance surface:

Supporting evidence from development-plan.md WP-U1 (“RealtimeHub SSE endpoint”): “A cross-origin Origin on /api/stream is rejected; no wildcard CORS.”

Consequences

Alternatives considered

As-built addendum 2026-09-26 — the open item closed. RealtimeHub keeps the last 256 frames and /api/stream replays every buffered frame after the request’s Last-Event-ID (which EventSource sends on its own reconnect) before the client joins live fan-out, in one synchronous step. Transport, origin check and auth gate are unchanged. Bounded, stated: a client gone longer than the window, or reconnecting across a server restart (ids restart at 1), still misses frames, and the dashboard still reports the id gap it can see.