Event stream and closure endpoint
A push-based refresh channel: instead of polling GET /manifest with If-None-Match, an SDK or backend service opens an SSE connection and receives the minimal closure delta the moment a new manifest version commits.
The wire format is protocol v2: each delta is hash-verified end-to-end and is paired with a signed-token snapshot endpoint that serves as the universal fallback.
| Endpoint | Auth | Status |
|---|---|---|
GET /api/v1/events | manifest.read per subscribed flag namespace | v0 |
GET /api/v1/tenants/{tenant}/namespaces/{slug}/closure | manifest.read or signed query token | v0 |
See conventions for the auth header, error envelope, and rate limits. See resolution for the evaluation algorithm whose inputs this stream refreshes.
Protocol version. v2 supersedes the per-flag fan-out format earlier prototypes shipped. v2 ships in lockstep with the SDK; there is no on-the-wire negotiation between v1 and v2, and the server emits v2 on every connection. Earlier client builds are not interoperable with a v2 server.
Surface overview
Two endpoints make up this surface:
GET /api/v1/events— the SSE stream itself. Inline closure deltas for ordinary updates, signed snapshot URLs for the first event after subscribe and any large/unrecoverable delta. The endpoint is not scoped under/tenants/{tenant}/, because token-bound principals are themselves tenant-scoped: subscription slugs are interpreted relative to the authenticated principal's tenant, and a single connection cannot fan in across tenants.GET /api/v1/tenants/{tenant}/namespaces/{slug}/closure— the snapshot endpoint that delivers a closure as atar.gzwhen an event opts fordelivery: snapshot, whenresyncfires, or when client-side hash verification fails.
GET /api/v1/events
Stream manifest version changes as Server-Sent Events (HTML Living Standard § SSE).
Auth required: manifest.read on every flag namespace listed in the subscription. A token without manifest.read on any one of the listed namespaces fails the connection with 403 Forbidden before the stream begins. namespace-client tokens cannot use this endpoint — they have no manifest.read permission and obtain fresh values via POST /evaluate{,/all} on every call.
Single-tenant per connection. A single connection can fan in changes from multiple flag namespaces within the authenticated tenant — the SDK's ExdClientGroup opens one connection regardless of how many flag namespaces in that tenant the calling process is using. ?ns= slugs are interpreted relative to the principal's bound tenant; subscriptions cannot cross tenants. (Superadmin tokens, which span the installation, must include the tenant inline as ?ns=<tenant>/<slug>:<flag-list>.)
CORS: not applicable. The endpoint is for trusted callers (SDK, agent, backend); browsers are not a target. Requests carrying an Origin header are accepted but no Access-Control-Allow-Origin header is emitted.
Subscription syntax
The subscription is encoded entirely in the query string (SSE constrains the request to HTTP GET). Repeat the ns parameter for each flag namespace; each value is <slug>:<flag-list> where <flag-list> is * (all flags) or a comma-separated list of flag keys.
?ns=billing:checkout-redesign,homepage-banner-copy
&ns=growth:*
&ns=experiments:onboarding-quiz-variant
| Limit | Value | Behaviour at limit |
|---|---|---|
| Namespaces per connection | 32 | 400 invalid_subscription (details.reason = "too_many_namespaces") |
| Flags per flag namespace | 64 | 400 invalid_subscription (details.reason = "too_many_flags", details.namespace) |
| Total query-string length | 8 KiB | 414 URI Too Long |
| Concurrent connections per token | 16 | 429 rate_limit_exceeded |
A * subscription counts as one entry against the per-namespace flag cap. Flag keys named in the subscription that do not exist in the flag namespace's current manifest are accepted (the connection succeeds; the closure simply omits the missing files). This is intentional: a flag may be added later, and the client will start receiving deltas for it the moment it is.
Subscriptions are immutable for the lifetime of one connection — to change the subscription, the client closes and reconnects.
Resume
The client may resume from a known version per flag namespace by sending either:
?since=<slug>:<version>,<slug2>:<version>query parameter, orLast-Event-ID: <slug>:<version>header (per SSE § 9.2.4 — automatically populated by browsers and well-behaved SSE clients on reconnect).
If both are supplied, Last-Event-ID overrides the matching ?since= entry for the same slug. The resume hint is treated as advisory — see First event after subscribe. Namespaces without a resume hint start at the current version.
Closure model
For a subscription (slug, flag_set) evaluated at commit C, the closure is the smallest set of manifest files sufficient to lint and evaluate the subscribed flags as a self-contained flag namespace:
closure(slug, flag_set, C) =
{ "namespace.toml" }
∪ { "flags/<f>.toml" | f ∈ flag_set ∩ flags_in(C) }
∪ { "segments/<s>.toml" | s ∈ segments_reachable(flag_set, C) }
segments_reachable(flag_set, C) is the transitive closure under segment-uses-segment references, computed against the typed model at commit C. A wildcard flag_set = * reduces to "every flag in the flag namespace, plus every reachable segment, plus namespace.toml" (orphan segments — declared but referenced by no flag — are included for * subscriptions and omitted for narrow subscriptions; this matches what lint_dir over the resulting sub-tree would expect).
The closure of a valid flag namespace is itself a valid flag namespace: the linter passes that fired at PUT time also pass against any closure of that namespace. The SDK runs its full lint::lint_dir against the closure tree and the resulting typed model contains exactly the subscribed flags. There is no "partial namespace" concept in the model layer.
Events
Three event types are defined: version (in two delivery modes), resync, and :keepalive comments.
event: version (inline delivery)
Used for ordinary updates whose path-keyed delta fits within the inline budget (≤ 64 KiB serialized JSON data).
event: version
id: billing:42
data: {
"protocol": "v2",
"namespace": "billing",
"version": 42,
"prev_version": 41,
"prev_closure_hash": "sha256:7a4f…",
"closure_hash": "sha256:9c2e…",
"delivery": "inline",
"files": [
{"path":"namespace.toml", "op":"modified", "sha256":"…", "content_b64":"…"},
{"path":"flags/checkout-redesign.toml", "op":"modified", "sha256":"…", "content_b64":"…"},
{"path":"segments/employees.toml", "op":"enter", "sha256":"…", "content_b64":"…"},
{"path":"segments/legacy-tier.toml", "op":"leave"}
]
}
| Top-level field | Description |
|---|---|
protocol | Always "v2". Reserved for future negotiation; the SDK rejects unknown values. |
namespace | Slug of the flag namespace whose version advanced. |
version | New manifest version number. |
prev_version | Version this delta is computed against. null if there is no prior closure for this subscription (impossible after the first event — see below). |
prev_closure_hash | Server's record of the closure hash before this delta is applied. Client compares to its tracked closure_hash and triggers snapshot fallback on mismatch. null only when prev_version is null. |
closure_hash | Server's record of the closure hash after this delta is applied. Client recomputes from its post-apply tree and triggers snapshot fallback on mismatch. |
delivery | "inline" or "snapshot". |
files | Path-keyed delta. Non-empty array of {path, op, sha256?, content_b64?} entries. |
Per-file op values:
op | Meaning |
|---|---|
added | File is new in the flag namespace and in this client's closure. Carries sha256 and content_b64. |
modified | File existed in the prior closure with different content. Carries sha256 and content_b64. |
removed | File was deleted from the flag namespace. No sha256 / content_b64. |
enter | File exists in the flag namespace at both versions but was not in the prior closure (the subscription closure now reaches it; e.g., a subscribed flag started using a segment). Carries sha256 and content_b64. Apply locally as if added. |
leave | File still exists in the flag namespace; no longer in this closure. No sha256 / content_b64. Apply locally as if removed. |
The enter / leave distinction is preserved on the wire so server-side metrics and diagnostics can report closure churn separately from manifest churn. From the client's perspective the application is identical: write the bytes, or remove the path.
content_b64 is the standard base64 encoding of the file's exact bytes from flags/<flag>.toml / segments/<seg>.toml / namespace.toml at commit C. sha256 is the lower-case hex SHA-256 over those bytes (no transcoding, no normalization). The client MUST verify sha256(decode(content_b64)) == sha256 before staging the file; mismatch triggers snapshot fallback.
files is non-empty by construction: if the closure for the new commit is byte-identical to the prior closure for this subscription, no event is emitted at all (the server's wake-up filter rejects the version).
event: version (snapshot delivery)
Used when:
- The inline serialization would exceed 64 KiB.
- This is the first event after subscribe (see below) — the client has no prior closure to diff against.
- The server cannot reconstruct a delta against
prev_version(the prior version was pruned past retention). - The new closure differs from the prior closure by more than 32 file entries (a churn-budget heuristic; tunable per deployment).
event: version
id: billing:42
data: {
"protocol": "v2",
"namespace": "billing",
"version": 42,
"prev_version": 41,
"prev_closure_hash": "sha256:7a4f…",
"closure_hash": "sha256:9c2e…",
"delivery": "snapshot",
"snapshot_url": "https://exd.example.com/api/v1/tenants/acme/namespaces/billing/closure?version=42&subscription=Y2hlY2tvdXQtcmVkZXNpZ24sZW1wbG95ZWVz&token=4f2c1ab9d3…",
"snapshot_size_bytes": 18432
}
| Field | Description |
|---|---|
snapshot_url | Absolute URL on the same host. The client issues an authenticated GET to retrieve the closure as tar.gz. The URL is server-signed (HMAC) and covers a 60-second window — see closure endpoint. |
snapshot_size_bytes | Uncompressed tarball size, advisory. The client can decide whether to fetch synchronously or stream-parse. |
prev_closure_hash may be null for the first event after subscribe (no prior state to verify against). closure_hash is always populated and the client MUST recompute and compare after applying the downloaded snapshot.
First event after subscribe
The first event the server emits on every new connection is always delivery: snapshot, regardless of whether the client supplied ?since= / Last-Event-ID and regardless of where that hint lands relative to retention. This makes the protocol's initial state synchronization a single mechanism (snapshot apply), avoids a "what was the client's prior closure?" round-trip on connect, and gives every connection a verified baseline before any inline delta is interpreted.
After the first event has been applied, subsequent updates use delivery: inline whenever they fit in the budget; only the conditions listed above force the server back to snapshot.
event: resync
Emitted when the server cannot deliver a closure for a requested resume version AND the client's prior subscription state is too divergent for the next inline delta to be interpreted safely. In v2 this is rare — the first-event-snapshot rule absorbs most of the cases that v1 used resync for — but the event remains for scenarios where the client must abandon its tracked state mid-stream:
event: resync
id: billing:42
data: {
"protocol": "v2",
"namespace": "billing",
"current_version": 42,
"reason": "version_too_old",
"snapshot_url": "https://exd.example.com/api/v1/tenants/acme/namespaces/billing/closure?version=42&subscription=…&token=…"
}
reason | Meaning |
|---|---|
version_too_old | ?since= / Last-Event-ID is below the retention horizon and the closure cannot be reconstructed. (Compare to delivery: snapshot first-event behavior, which is silent and benign — resync is only emitted when the server has already sent at least one inline event in the connection and now needs the client to discard tracked state.) |
unknown_since | ?since= / Last-Event-ID is greater than the server's current_version (e.g., server was rolled back or restored from backup). |
pre_git_era | The version predates the flag namespace's git history; not reconstructable. Possible only across a server upgrade boundary that backfilled git history. |
protocol_downgrade | Reserved for future use — the server cannot honor v2 for this subscription (would only fire under a misconfigured deployment; SDK treats as fatal). |
On resync the client fetches snapshot_url (same signed-token endpoint as delivery: snapshot) and resumes inline events from current_version. The connection stays open; subsequent version events arrive normally.
:keepalive
Comment lines emitted at most every 30 s when no other event has been sent. They keep proxies and load balancers from closing idle connections. Per the SSE spec, comment lines are ignored by parsers; clients do not need explicit handling.
Closure hash canonicalization
prev_closure_hash and closure_hash are computed identically on server and client over the closure post-application. The hash inputs are deterministic so the byte-exact comparison can be done with no normalization on either side:
- Take every file in the closure: pairs of
(path, content_bytes). - Sort by
pathlexicographically (std::cmp::Ordover the UTF-8 bytes —flags/a.toml < flags/b.toml < segments/a.toml). - For each pair, write to a hash buffer:
- 4 bytes — big-endian
u32length ofpath. pathbytes (UTF-8, no trailing NUL).- 32 bytes —
sha256(content_bytes)raw (not hex).
- 4 bytes — big-endian
- Compute
sha256of the hash buffer. - Format the result as
"sha256:" + lower-case hex digest.
The empty closure (no files) hashes to sha256(empty buffer); the server emits this only in the degenerate case where a wildcard subscription names a flag namespace whose manifest is empty. A test fixture in testplan/14a-closure-deltas.md carries a hand-checked reference value.
Hash-and-integrity contract
Every state transition is verified at three points:
- Pre-apply. On
delivery: inline, the client compares its trackedclosure_hashtoprev_closure_hash. Mismatch → snapshot fallback. (The first event has no pre-apply check.) - Per-file. For every entry with
content_b64, the client verifiessha256(decode(content_b64)) == sha256. Mismatch → snapshot fallback. - Post-apply. After applying every entry to a copy of the working tree, the client recomputes the closure hash. Mismatch with
closure_hash→ snapshot fallback.
The snapshot path adds a fourth check: after un-tarring, the client recomputes the closure hash over the result and compares to the event's closure_hash. Mismatch → re-fetch with backoff (the snapshot endpoint is idempotent and conditional via If-None-Match, so re-fetch is cheap).
A failure at any check leaves the prior typed model untouched. Eval continues to serve from the last successfully applied closure; the SDK surfaces the failure via last_refresh_error().
Ordering and monotonicity
Per flag namespace, version events on a single connection are strictly monotonic by version. Across flag namespaces on a multi-subscription connection, no ordering is guaranteed (a v3 of flag namespace A may appear before a v1 of flag namespace B even if B's commit landed first wall-clock). The SDK's apply mutex is keyed per slug so concurrent applications across namespaces don't serialize.
Per flag namespace, two consecutive events MUST satisfy event.prev_closure_hash == previous_event.closure_hash. Bug guard: a server that violates this is rejected by the client's pre-apply check and forced into the snapshot path (the protocol degrades, evaluation stays correct).
Retention
Each flag namespace retains the per-version closure-reconstruction metadata (manifest_version_files rows) for the larger of 30 days or 100 versions. Older manifest_versions rows remain queryable via GET /manifest/versions, but their per-version files index may be absent — this is the trigger for reason = "version_too_old" on event: resync and for delivery: snapshot on the first event after subscribe with a stale since.
Lifecycle
- The connection survives manifest pushes by other clients indefinitely.
- The server may close idle connections after 24 h. Clients reconnect with
Last-Event-ID/?since=; the next first event is again a snapshot. - The client closes by ending the underlying HTTP connection.
- The signed-snapshot URL (60 s TTL) outlives the request that issued it but does not outlive the connection by long; if the client reconnects, the next snapshot URL is freshly signed.
Error responses (before stream begins)
| Condition | HTTP | Error code |
|---|---|---|
No ns parameter | 400 | invalid_request |
Caller lacks manifest.read on any listed flag namespace | 403 | forbidden |
| Listed flag namespace does not exist | 400 | invalid_subscription (details.reason = "unknown_namespace") |
| Subscription exceeds caps | 400 | invalid_subscription |
| Query-string length exceeds 8 KiB | 414 | (no body) |
| Concurrent-connection cap exceeded | 429 | rate_limit_exceeded |
After the stream has begun, recoverable errors are encoded as event: resync payloads. Fatal errors close the connection; the client reconnects with the last seen event id and a fresh first-event snapshot reseats the state.
Failure handling and degradation
Every failure mode degrades to either retry-with-backoff or snapshot fallback; nothing is fatal at the SDK level. The single invariant: Namespace::eval() always serves from a successfully-linted closure — the only way that can change is a successful apply on the same connection or via snapshot. See SDK § Event-stream refresh for the SDK-side state machine.
Example
# Tenant `acme` is inferred from the bearer token's binding; subscription slugs are tenant-relative.
curl -N "https://exd.example.com/api/v1/events?ns=billing:checkout-redesign&ns=growth:*&since=billing:41" \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb"
The first event will be a delivery: snapshot for each subscribed flag namespace at its current_version. Subsequent events will be inline closure deltas as new manifest versions land.
GET /api/v1/tenants/{tenant}/namespaces/{slug}/closure
Fetch the closure for a (tenant, namespace, version, subscription) tuple as a tar.gz archive. This endpoint backs delivery: snapshot events, event: resync recovery, and any client-side hash-verification failure that needs to reset its tracked state.
Auth required: EITHER a manifest.read-bearing token (matching the SSE auth surface; its tenant binding MUST match {tenant}), OR a server-issued signed token query parameter that covers the requested (principal, tenant, namespace, version, subscription) tuple. Clients receive signed tokens in event: version (delivery: snapshot) and event: resync payloads; they do not construct or rotate them. See access-control § Closure endpoint.
Query parameters
| Parameter | Required | Description |
|---|---|---|
version | yes | Manifest version to materialize the closure against. Server rejects 0 and any version > current_manifest_version. |
subscription | yes | URL-safe base64 (no padding) over the canonical subscription string <flag-list> (e.g., * or flag1,flag2). Server compares for exact equality with the SSE-time subscription on signed-token requests. |
token | conditional | HMAC over (principal_id, tenant, namespace, version, subscription, expires_at) produced by the SSE handler at event-emit time. 60 s TTL. Constant-time comparison. Required when bearer auth is absent. |
Response: 200 OK
Content-Type: application/x-tar (the body is tar.gz-encoded).
ETag: "v<version>-<closure_hash>"
Cache-Control: private, max-age=60
Content-Length: <bytes>
The ETag carries both the manifest version and the closure hash so a conditional GET (If-None-Match: "v42-sha256:9c2e…") collapses to 304 Not Modified when the SDK re-requests the same closure after a transient parse error.
Error responses
| Condition | HTTP | Code |
|---|---|---|
version missing or invalid | 400 | invalid_request |
subscription missing or malformed | 400 | invalid_request |
token invalid, expired, or scope mismatch (incl. tenant mismatch) | 401 | closure_token_invalid |
Caller (bearer-auth path) lacks manifest.read on (tenant, slug) | 403 | forbidden |
(tenant, slug) does not exist | 404 | namespace_not_found |
version > current_manifest_version | 404 | namespace_not_found (visibility) |
Closure for version cannot be reconstructed (pruned) | 410 | closure_unavailable |
Idempotence and caching
The endpoint is purely a function of (tenant, slug, version, subscription) against committed manifest history. Identical requests return identical bytes. Servers are encouraged to memoize generated tarballs in a small per-process LRU keyed by (tenant, slug, version, canonical_subscription); the wakeup-filter path inside the SSE handler already retains them for the lifetime of the connection.
Example (snapshot URL produced by an event)
curl -sS "https://exd.example.com/api/v1/tenants/acme/namespaces/billing/closure?version=42&subscription=Y2hlY2tvdXQtcmVkZXNpZ24sZW1wbG95ZWVz&token=4f2c1ab9d3…" \
-H "Authorization: Bearer exd_read_4tRvBn…" \
-o billing-v42-closure.tar.gz
The SDK consumes signed-token URLs verbatim from event payloads; operators rarely call this endpoint manually. If you need an ad-hoc closure for debugging, omit token and rely on the bearer credential.
See also
- manifest § GET /manifest — the polling alternative.
- access-control § Event stream — the per-namespace authorization rules.
- access-control § Closure endpoint — the bearer + signed-token dual-auth model.
- tokens —
manifest.readis granted bynamespace-read,namespace-write,tenant-admin, andsuperadmintokens. - SDK § Event-stream refresh — the client-side state machine.