# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history - Status: Accepted - Date: 2026-08-25 - Scope: `adapter:inbound:web` — `advanced.stream.**` ## Context Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path. One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot change.** A stream that ends because a dependency failed and one that ends because it finished are identical at the transport layer — both are a closed connection after a 200. So is a stream that was cut off mid-flight. The second fact is that a resume path invites the web module to remember things. It must not: the messaging platform already owns durable event history, and a second copy would have its own retention, its own eviction and its own opinion about ordering. ## Decision **Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off, and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is where a rising rate of mid-stream failures would otherwise hide. **Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper` branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real status; after, a terminal record. Attempting both produces a body that is half stream and half JSON, which no client parses and every proxy caches as a success. **Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a repeated or regressing position. A client deduplicating on position would silently drop the second item. **A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a consumer that reads slowly for an hour. **Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks clients to reconnect, and only then forces the remainder — because a client whose socket is cut retries immediately, and if every socket is cut at once, every client retries at once. **The web module stores no durable history.** `WebStreamReplaySource` is an interface this module implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from the oldest retained position, because that delivers a stream with a hole the client cannot see. **The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is visible in either half on its own. ## Consequences - Clients must handle three outcomes. A client that treats a closed connection as completion will be wrong, and no server change can fix that for it. - An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer. - JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away. ## Alternatives considered - **Emit a problem document when a stream fails after commit.** Rejected: the body becomes unparseable and the 200 is cached. - **Resume from the oldest retained position when the cursor expires.** Rejected: positions are contiguous from where the replay started, so nothing in the data says events are missing. - **Store replay history in the web module.** Rejected: a second source of truth that drifts invisibly. - **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the server's heap.