Problem
A customer's API key reads the platform through REST, one request at a time, and
everything about a request is checked as it arrives: the token at the edge, the scope
per route, the rate per subject, the units per account (RFC 0015, RFC 0016). The API
also streams. It offers server-sent events on every route that ends in /events, a
multiplexed WebSocket that carries every public call over one connection, and MQTT for
devices (RFC 0022). A key could already open all three: the edge admits a scoped token
at the socket's upgrade and the gateway checks each call against the key's scopes. What
the platform had not decided is everything that happens after a stream is open, and
that is where a stream differs from a request:
- Nothing bounds how many. The per-subject request rate counts the requests that open streams, not the streams left open. One key could hold thousands of event streams or sockets on a replica, each holding memory and a connection to a backend.
- A revoked key keeps streaming. Revocation works on the next call (RFC 0016: within seconds). A stream opened before the revocation makes no next call. It goes on until the client hangs up.
- A quiet socket looks dead, and a dead one looks quiet. The socket sends nothing while its streams have nothing to say, so a client cannot tell a quiet stream from a dropped connection. Nothing closes a socket that nobody uses.
- The record is uneven. MQTT writes one audit line per connect, subscribe, call and disconnect. The socket writes none per call, and an event stream's life (who held it, for how long, why it ended) is in no audit line.
- The socket's contract is prose. The REST surface is an OpenAPI document that the client libraries are generated from (RFC 0020). The socket's frames are described only in a table in the gateway's documentation, so the SDKs cannot follow them the way they follow the REST contract.
The SDKs' streaming milestone waits on this: libraries that open streams for customers should do so against limits the platform states, not limits it happens to have.
Proposal
What a key may open: what its scopes admit, nothing else. A streaming route is a
GET, and a key's scope admits it exactly when it admits the GET, the same table the edge
uses for REST calls, the same table the OpenAPI document marks public, and the same
table the accounts service offers when a key is made. Today that is the account's event
stream, admitted by events:read. On the socket and on MQTT every call and every
subscription is checked against the same table, and anything no scope names is refused
(forbidden on the socket, reason code 0x87 on MQTT). Default deny: a new stream
opens to keys only through a scope, and a new scope only through an RFC.
How many at once. Each open stream holds a place: an event stream, a socket and an
MQTT connection count one each. A key, an API token or a signed-in person holds at most
32 at once, an account at most 128 across all its keys, tokens and people. One more is
refused before it opens and before any backend sees it: 429 rate_limited with
Retry-After: 1 on an event stream and on the socket's upgrade, CONNACK reason
0x97 (Quota exceeded) on MQTT. Inside a stream the existing bounds hold: 64 calls in
flight and 256 KiB frames on the socket, 32 subscriptions and 16 calls per MQTT
connection, one bounded queue per connection, so a slow reader stalls only itself.
How long. A stream lives at most 24 hours. Then it ends with unavailable, which
says: open it again. On the socket the server sends a ping every 15 seconds, so the
client and every proxy see the connection alive while its streams are quiet, and a
socket with no call in flight and nothing from the client for five minutes is closed
(code 1000, idle). MQTT keeps its own keep-alive.
A revoked key's streams end. A stream opened with a key or an API token checks every
five seconds whether the credential was revoked, using the same list the gateway already
refuses calls by, and keeps that credential on the list of those it asks about. When it
was revoked the stream ends with unauthenticated: an error event on an event stream,
an error frame without a call id on the socket followed by the close, DISCONNECT
0x87 (Not authorized) on MQTT. A revocation that used to stop the next call now stops
the open streams too, within seconds.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
Metering does not change. Opening a stream is one call of its operation, counted when the backend answers (RFC 0015); the messages it carries cost nothing more, except the tokens a model's answer says it used. A key that has used its account's units is refused when it opens a stream, as when it makes a call.
On record. Every stream writes one audit line when it opens or is refused and one when it closes: the surface, who (subject, kind of caller, account, key), what (the operation, never a value from the request), the outcome (admitted; refused for the caller or the account; closed, revoked, or at its longest life) and how long it lived. Every call on the socket writes one line too, as MQTT's calls do. Never a payload, a query value or an event. Two counters show refusals and the ends the gateway chose.
The socket's frames, published. The gateway serves the JSON Schema of the socket's
frames without a token beside the OpenAPI document, with the limits above in it, and the
OpenAPI document names each operation's call in x-iohr-rpc, which is what a socket
frame addresses. The client libraries sync the schema as they sync the OpenAPI document,
and a test keeps the schema equal to the frames the gateway actually sends.
Alternatives considered
- End a stream when the token that opened it expires. A key's tokens live 15 minutes, so every stream would end four times an hour and lose what arrived during the reconnect, for no gain: the key behind an expired token is still valid, and a revoked one is caught by the check above within seconds. The bound that matters is the credential's, not the token's.
- Count streams across all replicas. Exact, but it needs shared state on every stream's open and close. Per-replica counts bound memory and backend connections, which is the harm; the account limit is far above any real use and a per-replica count can only be stricter for a client that lands on one replica.
- A separate scope to stream. A key that may read the account's events may read them as they happen; a second scope would be a second decision with the same answer. The scope admits the route, whatever the transport.
- Heartbeats as frames on the socket. A
heartbeatframe would be visible to every client, including browsers, which cannot see pings. It would also change a frame set that clients already parse. WebSocket pings are the protocol's own keep-alive and every client library answers them.
Decision
Keys stream what their scopes admit, under per-caller and per-account limits, for at most a day, ended within seconds of a revocation, with an audit line at each end. The socket's frames are a published schema. This changes an access control (keys on streaming surfaces) and adds availability bounds; both are recorded with the platform's security controls.
Publication
The gateway enforces the limits on all three surfaces and serves the frame schema; the OpenAPI document marks the event stream public with its scope and names every operation's call. The developer documentation's streaming guide states the limits and how each surface ends a stream. The SDKs' streaming milestone builds on this: event streams and the socket in every language, against the synced frame schema.
Status log
- 2026-10-04: opened. Measured before: a key with
events:readopened the event stream and a socket, and a call outside its scopes was refused on the socket withforbidden; nothing bounded how many streams it held, and a stream outlived its key's revocation. - 2026-10-04: built in the gateway: the per-caller and per-account limits, the longest
life, the revocation check, the socket's ping and idle close, audit lines for every
stream and every socket call, the frame schema served without a token, and
x-iohr-rpcon every operation. Tests open real streams against a real gateway and a real accounts service and revoke a key under them. - 2026-10-04: live. Measured with a throwaway account's keys: a key with
events:readopened the event stream, a socket and MQTTevents/#(granted); aradar:readkey was refused all three (403,forbiddenframe, SUBACK0x87). One key opened 64 sockets across the two gateway replicas and the 65th was refused429 rate_limitedwithRetry-After: 1. Revoking the key in the console ended its open socket call with anunauthenticatedframe 2.4 s later and its event stream with anunauthenticatedevent 7.3 s later. The event stream keeps the events service's own bound of 10 per account. - 2026-10-07: Decided: built and live, measured on 2026-10-04 (#228, #231); protocol runs at dab02d1a.