Skip to main content

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.

EndpointAuthStatus
GET /api/v1/eventsmanifest.read per subscribed flag namespacev0
GET /api/v1/tenants/{tenant}/namespaces/{slug}/closuremanifest.read or signed query tokenv0

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 a tar.gz when an event opts for delivery: snapshot, when resync fires, 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
LimitValueBehaviour at limit
Namespaces per connection32400 invalid_subscription (details.reason = "too_many_namespaces")
Flags per flag namespace64400 invalid_subscription (details.reason = "too_many_flags", details.namespace)
Total query-string length8 KiB414 URI Too Long
Concurrent connections per token16429 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, or
  • Last-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 fieldDescription
protocolAlways "v2". Reserved for future negotiation; the SDK rejects unknown values.
namespaceSlug of the flag namespace whose version advanced.
versionNew manifest version number.
prev_versionVersion this delta is computed against. null if there is no prior closure for this subscription (impossible after the first event — see below).
prev_closure_hashServer'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_hashServer'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".
filesPath-keyed delta. Non-empty array of {path, op, sha256?, content_b64?} entries.

Per-file op values:

opMeaning
addedFile is new in the flag namespace and in this client's closure. Carries sha256 and content_b64.
modifiedFile existed in the prior closure with different content. Carries sha256 and content_b64.
removedFile was deleted from the flag namespace. No sha256 / content_b64.
enterFile 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.
leaveFile 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
}
FieldDescription
snapshot_urlAbsolute 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_bytesUncompressed 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=…"
}
reasonMeaning
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_eraThe version predates the flag namespace's git history; not reconstructable. Possible only across a server upgrade boundary that backfilled git history.
protocol_downgradeReserved 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:

  1. Take every file in the closure: pairs of (path, content_bytes).
  2. Sort by path lexicographically (std::cmp::Ord over the UTF-8 bytes — flags/a.toml < flags/b.toml < segments/a.toml).
  3. For each pair, write to a hash buffer:
    • 4 bytes — big-endian u32 length of path.
    • path bytes (UTF-8, no trailing NUL).
    • 32 bytes — sha256(content_bytes) raw (not hex).
  4. Compute sha256 of the hash buffer.
  5. 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:

  1. Pre-apply. On delivery: inline, the client compares its tracked closure_hash to prev_closure_hash. Mismatch → snapshot fallback. (The first event has no pre-apply check.)
  2. Per-file. For every entry with content_b64, the client verifies sha256(decode(content_b64)) == sha256. Mismatch → snapshot fallback.
  3. 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)

ConditionHTTPError code
No ns parameter400invalid_request
Caller lacks manifest.read on any listed flag namespace403forbidden
Listed flag namespace does not exist400invalid_subscription (details.reason = "unknown_namespace")
Subscription exceeds caps400invalid_subscription
Query-string length exceeds 8 KiB414(no body)
Concurrent-connection cap exceeded429rate_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

ParameterRequiredDescription
versionyesManifest version to materialize the closure against. Server rejects 0 and any version > current_manifest_version.
subscriptionyesURL-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.
tokenconditionalHMAC 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

ConditionHTTPCode
version missing or invalid400invalid_request
subscription missing or malformed400invalid_request
token invalid, expired, or scope mismatch (incl. tenant mismatch)401closure_token_invalid
Caller (bearer-auth path) lacks manifest.read on (tenant, slug)403forbidden
(tenant, slug) does not exist404namespace_not_found
version > current_manifest_version404namespace_not_found (visibility)
Closure for version cannot be reconstructed (pruned)410closure_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