# RFC 0247: Bounded stream lifetime: a host may end an opted-in live SSE stream, and the consumer resumes with Last-Event-ID

> Source: https://openwop.dev/rfcs/0247-bounded-stream-lifetime.html · OpenWOP, the open-source protocol for multi-agent workflow orchestration. CC BY 4.0.

| Field             | Value                                                           |
| ----------------- | --------------------------------------------------------------- |
| **RFC**           | 0247                                                            |
| **Title**         | Bounded stream lifetime: a host may end an opted-in live SSE stream, and the consumer resumes with Last-Event-ID |
| **Status**        | `Accepted`                                                      |
| **Author(s)**     | David Tufts (@davidscotttufts)                                  |
| **Created**       | 2026-10-15                                                      |
| **Updated**       | 2026-10-16 — **`Active → Accepted`, provisional pending the RFC 0156 §B retrospective review** (register row `not-reviewed`; no RFC 0147 §A.6 override, as at `Active`). Evidence tier: tier-1 on both halves. Host: openwop-app (steward production host, ADR 0897) advertises `limits.maxStreamAgeSec: 300` (live `/.well-known/openwop` at `api.openwop.dev`, fetched by the steward 2026-10-16) and bounds opted-in streams; its certified production cut on published suite 2.46.12 (`evidence/v2-host-bundles/openwop-workflow-engine-2.46.12-aee0febbc.json`, build `commit:aee0febbc`, revision `openwop-app-backend-00983-dg2`, signed `openwop-app-bundle-2`, sha256 `49ba4f222dc8…`, 446 pass / 0 fail / 0 blocked, `--verify --host-key` VERIFIED) carries the advert in its discovery document and records all 12 resume rows of `v2-sse-last-event-id`, `v2-sse-last-event-id-cursor`, `v2-stream-sse-projection`, `v2-stream-mode-refusal` and `v2-run-pause-resume` `executed-pass`, with no result changed against the 2.46.9 cut. The bounded close itself is not a suite leg (G1); openwop-app measured it in production on 2026-10-16: an opted-in direct `/host/events` stream was ended at 295.8 s with `retry: 1000`, and one through the Firebase Hosting `/api` hop at 47.0 s with a single upstream. SDKs: openwop-sdks tag `v2.6.0` (commit `dfc6bd4`) sends `resumable=true` and resumes on a clean close in TypeScript (`sse.ts`), Python (`sse.py`) and Go (`sse.go`), each with a resume test. · 2026-10-15 — **`Draft` → `Active` in the filing PR, comment window waived by the maintainer (user instruction 2026-10-15: "Proceed with all phases")**. Routine bootstrap waiver under GOVERNANCE §"Sole-steward operation": RFC 0147 §A.6 does not apply (no identity, authorization, isolation, idempotency, replay, external-effect or certification surface; no conformance rows). Wire surface in this PR: the optional `resumable` query parameter on `streamRunEvents` and `streamHostEvents`, and the optional `limits.maxStreamAgeSec` facet. `Accepted` waits on a host advertising `maxStreamAgeSec` and bounding opted-in streams while passing the existing `Last-Event-ID` resume legs, and on the v2 SDKs opting in and resuming. |
| **Affects**       | `spec/v2/core/events.md` (§Bounded close) · `spec/v2/facets/limits.schema.json` (new hand-decided override; nine seeded shapes unchanged, plus `maxStreamAgeSec`) · `spec/v2/declaration.json` (`limits` facet list; `events.md` added as a second `limits` home, which its Normative-home line now claims) · `api/v2/openapi.yaml` + `api/v2/asyncapi.yaml` (generated by `scripts/derive-v2-api.py`: the `resumable` query parameter) |
| **Compatibility** | `additive` per `COMPATIBILITY.md` §2.1 and §4 ("New optional capability advertised, off by default — Yes, additive"). One optional query parameter and one optional facet. A request without `resumable=true` is served exactly as today: the `events.md` "MUST hold the stream open" rule is unchanged for it, so no existing `MUST` is relaxed for any consumer that has not asked (§2.2). |
| **Supersedes**    | —                                                               |
| **Superseded by** | —                                                               |

## 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 `EventSource` reconnects after a normal close, sends `Last-Event-ID`, and honours `retry:` (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 advertising `maxStreamAgeSec` MAY 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 `bufferMs` rule already requires a flush on close). It SHOULD first send an SSE `retry:` 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 without `resumable=true` early, 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=true` and `Last-Event-ID` set to the last `id:` 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 `maxStreamAgeSec` is 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

```http
GET /runs/run_01J…/events?resumable=true HTTP/1.1
OpenWOP-Version: 2
Last-Event-ID: 41
```

A bounded close, then the resume:

```text
id: 57
event: node.completed
data: {…}

retry: 1000

(connection closes; no run.completed was sent)
```

```http
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 existing `MUST` within a major. A host may end a stream early only for a consumer that sent `resumable=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 `maxStreamAgeSec` never bounds a stream. `limits` is 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. No `eventLogSchemaVersion` bump.
- **A first draft was rejected as breaking.** An unconditional "a host MAY end a live stream after a bounded age" relaxes the `events.md` hold-open `MUST` for 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

1. **Let a host bound every stream (no opt-in).** Rejected as a §2.2 relaxation of the hold-open `MUST` (see Compatibility).
2. **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.
3. **Detect disconnects host-side** (TCP keep-alive, write errors). Rejected: not observable behind a buffering proxy, which is the case that leaks.
4. **An opt-in header.** Rejected: a browser `EventSource` cannot set request headers, and browsers are the main consumer behind CDNs.
5. **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

1. **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.
2. **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.json` carries `maxStreamAgeSec` with the §B and §C rules in its description; the nine seeded facets are unchanged.
- [x] `api/v2/openapi.yaml` and `api/v2/asyncapi.yaml` carry `resumable` on both operations (generated).
- [x] CHANGELOG entry.
- [x] A host advertises `maxStreamAgeSec`, bounds opted-in streams, and passes the existing `Last-Event-ID` resume legs (openwop-app, 2.46.12 cut `aee0febbc`, 2026-10-16).
- [x] The v2 SDKs (TypeScript, Python, Go) send `resumable=true` and resume on a clean close (openwop-sdks `v2.6.0`, `dfc6bd4`).

## References

- `spec/v2/core/events.md` §SSE frames, §Resuming with `Last-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).
