OpenWOP openwop.dev
RFC details authors, dates, what it affects, review history
FieldValue
RFC0247
TitleBounded stream lifetime: a host may end an opted-in live SSE stream, and the consumer resumes with Last-Event-ID
StatusAccepted
Author(s)David Tufts (@davidscotttufts)
Created2026-10-15
Updated2026-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.
Affectsspec/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)
Compatibilityadditive 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

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

RequirementObservableWho can cause the conditionVerdict
§A without resumable=true the stream is held opena live run's stream stays open past the advertised agethe suite, given a run that stays live longer than maxStreamAgeSecwitnessable in principle; no leg yet (G1)
§B no bounded close before 15 s, and none past maxStreamAgeSecthe close time of an opted-in stream on a live runthe suite, same runwitnessable in principle; no leg yet (G1)
§B a frame boundary and a flushed batchthe last bytes before close parse as whole framesthe suite, same runwitnessable in principle; no leg yet (G1)
§B a non-advertising host never boundsno early close of an opted-in stream on a host without the facetthe suite, same runwitnessable in principle; no leg yet (G1)
§C the consumer resumesthe consumer's reconnect carries resumable=true and Last-Event-IDthe consumer itself; a host suite never observes a consumer's reconnectunwitnessable — 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).

More proposals.

Edit this page on GitHub ↗