Summary
A consumer that can resume adds resumable=true to GET /runs/{runId}/events or GET /host/events. For that stream only, a host advertising limits.maxStreamAgeSec MAY end the stream before the run's terminal event once it has been open 15 seconds, and MUST end it by the advertised age. The host ends at a frame boundary, flushes any batch, and SHOULD first send an SSE retry: field. Such a "bounded close" is never terminal: the consumer MUST reconnect with resumable=true and Last-Event-ID. Without the parameter, nothing changes.
Motivation
events.md requires a host to hold a live run's stream open until the terminal event. A host cannot tell when the consumer has gone, so an abandoned stream holds host resources until something else closes it.
- Disconnects are invisible behind common front ends. Cloud Run's documentation states that with HTTP/1.1 "client disconnect events are not propagated to the Cloud Run container" (docs.cloud.google.com/run/docs/troubleshooting). Measured on openwop-app on 2026-10-15: a client that disconnected after 20 s left its stream open on the host for 3601 s, the platform's request timeout. One client through a Firebase Hosting rewrite left about 18 of a tenant's 20 stream slots held after three minutes, because the CDN retries the upstream request about every 60 s after the client has gone.
- Every real deployment already bounds streams. Cloud Run ends a request at 3600 s, Firebase Hosting at 60 s, and load balancers and proxies at their own idle or request timeouts. An unbounded hold was never achievable, so a consumer that wants a whole run already has to survive a close it did not ask for. What it lacks is a way to know the host intends one.
- The pattern is established. The WHATWG
EventSourcereconnects after a normal close, sendsLast-Event-ID, and honoursretry:(html.spec.whatwg.org, §Server-sent events). The Mercure hub closes subscriber connections by design (write_timeout, default 600 s) and relies on that reconnect.
A host-side bound frees an abandoned stream within the bound, whatever lies between host and consumer. Last-Event-ID already makes the resume lossless (events.md §Resuming with Last-Event-ID). This RFC adds the opt-in and the rules.
Proposal
§A — The consumer's opt-in
streamRunEvents and streamHostEvents gain an optional boolean query parameter, resumable (default false). It is a query parameter, not a header, because a browser EventSource cannot set request headers.
- Absent or
false: the host behaves exactly as before. On a live run it MUST hold the stream open for later events. true: the consumer declares it implements §C, and §B applies.
§B — The host's bounded close
limits.maxStreamAgeSec (integer ≥ 15) is the longest, in seconds, the host holds an opted-in stream.
- For a stream opened with
resumable=true, a host advertisingmaxStreamAgeSecMAY end it before the run's terminal event once it has been open 15 seconds, and MUST end it by that age. - It MUST end at a frame boundary and MUST flush any pending batch first (the existing
bufferMsrule already requires a flush on close). It SHOULD first send an SSEretry:field giving the reconnect delay in milliseconds. - A host MUST NOT end an opted-in stream early unless it advertises
maxStreamAgeSec. It MUST NOT end a stream opened withoutresumable=trueearly, whatever it advertises. - A bounded close is never a terminal signal; only the terminal event is.
A host MAY choose a shorter close within the window for a particular hop; for example, a host behind a 60 s front end can close at about 50 s there and at the advertised age elsewhere.
§C — The consumer's resume
A consumer that sent resumable=true:
- MUST treat any close without the terminal event (a bounded close, a network drop, an intermediary's timeout) as non-terminal;
- MUST reconnect with
resumable=trueandLast-Event-IDset to the lastid:it received, with no header if it received none; - SHOULD wait the
retry:delay, or its own default of about one second, and SHOULD back off when reconnects fail or close without a frame; - MUST handle such a close whether or not
maxStreamAgeSecis advertised.
On hostEvents the consumer resumes from the last durable event's id:. Ephemeral events missed during the gap are not redelivered, as events.md §Host events already says.
Where the text lives
events.md §Bounded close states the rule in two sentences and points at the facet; the full host and consumer rules live in the limits.maxStreamAgeSec description in spec/v2/facets/limits.schema.json and in the resumable parameter description in api/v2/openapi.yaml. This keeps the kernel inside its RFC 0190 word budget: it goes from 37,999 to 38,000 of 38,000 words, because three redundant clauses elsewhere in events.md were shortened without changing their meaning (a restated list of terminal types, a rationale parenthetical, and a doubled clause about an absent envelope catalog).
Examples
GET /runs/run_01J…/events?resumable=true HTTP/1.1
OpenWOP-Version: 2
Last-Event-ID: 41
A bounded close, then the resume:
id: 57
event: node.completed
data: {…}
retry: 1000
(connection closes; no run.completed was sent)
GET /runs/run_01J…/events?resumable=true HTTP/1.1
Last-Event-ID: 57
Compatibility
Additive (COMPATIBILITY.md §2.1, §4).
- No MUST is relaxed for anyone who did not ask.
COMPATIBILITY.md§2.2 forbids relaxing an existingMUSTwithin a major. A host may end a stream early only for a consumer that sentresumable=true, which no consumer written before this RFC sends. That consumer sees exactly the old behaviour. - The facet is off by default. A host that does not advertise
maxStreamAgeSecnever bounds a stream.limitsis a stable family; adding an optional facet to a closed v2 record is additive (§2.4). - No event or payload shape changes.
retry:is a standard SSE field, which every conforming SSE consumer already tolerates. NoeventLogSchemaVersionbump. - A first draft was rejected as breaking. An unconditional "a host MAY end a live stream after a bounded age" relaxes the
events.mdhold-openMUSTfor every consumer, including SDKs that stop at a clean close. That is a §2.2 violation and could only ship at v3; the opt-in is what makes this additive.
Conformance
Existing coverage: the Last-Event-ID resume legs (exclusive cursor, no re-emission, caught-up live resume held open, terminal resume closed without a frame).
No new leg in this RFC. §B is a MAY behind an advertisement, and the age bound needs a stream held open for at least 15 seconds against a live run, which the suite's stream waits (10–60 s) do not budget for. A cheap leg would be vacuous: it can only observe that a host did not close early. The claim that matters, that a resume after a bounded close loses nothing, is what the existing resume legs already test.
Falsifiability — one row per normative requirement
| Requirement | Observable | Who can cause the condition | Verdict |
|---|---|---|---|
§A without resumable=true the stream is held open | a live run's stream stays open past the advertised age | the suite, given a run that stays live longer than maxStreamAgeSec | witnessable in principle; no leg yet (G1) |
§B no bounded close before 15 s, and none past maxStreamAgeSec | the close time of an opted-in stream on a live run | the suite, same run | witnessable in principle; no leg yet (G1) |
| §B a frame boundary and a flushed batch | the last bytes before close parse as whole frames | the suite, same run | witnessable in principle; no leg yet (G1) |
| §B a non-advertising host never bounds | no early close of an opted-in stream on a host without the facet | the suite, same run | witnessable in principle; no leg yet (G1) |
| §C the consumer resumes | the consumer's reconnect carries resumable=true and Last-Event-ID | the consumer itself; a host suite never observes a consumer's reconnect | unwitnessable — consumer behaviour, outside what a host suite can observe; witnessed instead by the v2 SDK test suites (openwop-sdks) |
Alternatives considered
- Let a host bound every stream (no opt-in). Rejected as a §2.2 relaxation of the hold-open
MUST(see Compatibility). - End-to-end HTTP/2 so disconnects propagate. Cloud Run documents it as the fix for its own platform. Rejected as a protocol rule: it is one platform's deployment detail, and other front ends hide disconnects anyway. A host may still do it.
- Detect disconnects host-side (TCP keep-alive, write errors). Rejected: not observable behind a buffering proxy, which is the case that leaks.
- An opt-in header. Rejected: a browser
EventSourcecannot set request headers, and browsers are the main consumer behind CDNs. - Status quo. Rejected: abandoned streams hold host resources until a platform timeout, which on one measured host exhausted a tenant's stream slots.
Unresolved questions
- A suite leg that waits. Should the suite gain a long-hold leg (gated, opt-in by the operator) that opens an opted-in stream on a live run past the advertised age? Recorded as G1.
- Discovery of the hop-specific age. A host behind a short front end closes sooner on that hop than its advertised age. Is "at most
maxStreamAgeSec" enough for consumers to plan, or should a hop announce its own bound? Recorded as G2.
Implementation notes (non-normative)
openwop-app (ADR 0897) advertises maxStreamAgeSec, bounds opted-in run and host-event streams, and closes them at about 50 s on the Firebase Hosting hop so that path works as long-polling. Its SPA opts in. openwop-sdks opts in from streamEvents / streamHostEvents and resumes on a clean close.
Acceptance criteria
- [x]
events.md§Bounded close, inside the kernel budget (38,000 / 38,000). - [x]
spec/v2/facets/limits.schema.jsoncarriesmaxStreamAgeSecwith the §B and §C rules in its description; the nine seeded facets are unchanged. - [x]
api/v2/openapi.yamlandapi/v2/asyncapi.yamlcarryresumableon both operations (generated). - [x] CHANGELOG entry.
- [x] A host advertises
maxStreamAgeSec, bounds opted-in streams, and passes the existingLast-Event-IDresume legs (openwop-app, 2.46.12 cutaee0febbc, 2026-10-16). - [x] The v2 SDKs (TypeScript, Python, Go) send
resumable=trueand resume on a clean close (openwop-sdksv2.6.0,dfc6bd4).
References
spec/v2/core/events.md§SSE frames, §Resuming withLast-Event-ID, §Batching and subscribers, §Host events.COMPATIBILITY.md§2.1, §2.2, §2.4, §4.- Cloud Run troubleshooting (client disconnects under HTTP/1.1): https://docs.cloud.google.com/run/docs/troubleshooting
- Firebase Hosting with Cloud Run (60-second request timeout): https://firebase.google.com/docs/hosting/cloud-run
- WHATWG HTML §Server-sent events: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Mercure, rolling updates and reconnection: https://mercure.rocks/docs/hub/rolling-updates
- openwop-app ADR 0876 (front-door refusal of host-extension streams) and ADR 0897 (bounded stream lifetime).