# The dawn WebSocket binding

**Status:** current binding of the 2026-09-19 development surface, beside the HTTP
binding. [STATUS.md](../STATUS.md) records candidate and deployment evidence. It
carries the same surface, [SURFACE.md](SURFACE.md), over one socket: every
operation of [operations.json](operations.json) marked `ws` is a message of
the same name, with the same parameters, the same body, the same result and
the same refusals as over HTTP. It adds no meaning to the surface: the two
rows it alone carries, `subscribe` and `unsubscribe`, are the surface's own,
and everything they deliver is defined there. Where this document and the
surface disagree, the surface wins and this file is wrong.

A socket is not a session **for calls**, and it is the one binding that carries
**subscriptions**. The service holds no call state per connection: a cutoff or
cursor reported on one connection is valid on another, a receipt is recovered
by operation ID exactly as over HTTP, and a publishing verb whose reply was
lost is resolved by resending the same operation ID. A subscription is the one
thing that is per-connection, and it is explicit: a client opens it by name
with `subscribe`, every frame it causes names it, it expires on its own
schedule, and it ends with its connection
([decision 0004](../docs/decisions/0004-world-subscriptions.md)).

The correlation id below is not the operation ID and never enters a canonical
form, and neither does a subscription id. A client may send the same operation
ID twice on one socket (a retry after an unknown outcome) and matches each
reply by its own correlation id. Authentication is per call: every envelope
carries its own proof, because a proof is one attempt. A subscription is
authorized once, by the proof on its `subscribe`, and expires with the grant
that authorized it — which is why its frames carry no proof and why it cannot
run forever.

## Upgrade

`GET /v1/ws` with an HTTP upgrade. The client **must** offer the subprotocol
`dawn`; that is how the service name travels. A socket accepted without it is
closed at once with `1002` and the reason `subprotocol dawn required`. No
origin check is made: authentication is the proof in every envelope, so an
origin check would add nothing a browser could not forge by holding a key.
The upgrade response carries `Ulab-Service` and `Ulab-Contract` like every
HTTP response.

The route is relative to the operations listener. Under the standard deployment
mount, the external upgrade URL is `/api/operations/v1/ws`; the proxy removes
`/api/operations` before forwarding. The signing audience remains the complete
HTTP(S) operations base URL, including `/api/operations`, as defined by the
[HTTP binding](HTTP.md). Management has no WebSocket binding.

## Frames

Text frames only, each one JSON object. A binary frame closes the socket with
`1003`. A frame **from the client** that is not an object with an `id`, or
whose `id` is empty, closes the socket with `1007`: without an id there is
nothing to answer. Frames **from the service** are replies, which carry the
`id` they answer, and subscription frames, which carry a `subscription`
instead; a client sends neither. The
read limit is the HTTP body limit, 64 MiB, plus envelope headroom; a larger
frame closes the socket with `1009` as the library enforces it. `retain` and
`fetch`, the two streams, are HTTP only and are refused here with `invalid`.

## Calls and replies

```json
{"id": "7", "op": "publish", "params": {}, "body": {…},
 "auth": {"proof": {…}, "grants": [{…}]}}
```

| Member | Meaning |
|---|---|
| `id` | correlation id, chosen by the client, 1..128 characters, unique among the calls in flight on this connection; reusing an in-flight id is refused for that call, not the socket |
| `op` | an operation name of the table; an unknown one, the removed `fork`, `list`, `history` and `view` included, is `not_found` with the message `no such operation` |
| `params` | the operation's parameters by their table names; `string` kinds as JSON strings, `int` kinds as JSON integers, `strings` kinds (a path) as arrays of strings; an unknown name, a wrong kind (a path as one string, a quoted number) or an empty string is refused before the operation is evaluated, as `invalid`, or as `unverified` when what the binding parsed no longer matches the signed record; the coordinate and cutoff domain rules are HTTP.md's |
| `body` | the request, for operations with a request type; a body on another operation is `invalid`; decoded by the same strict rules as an HTTP body |
| `auth` | the Carriage, the same object the HTTP binding carries base64-encoded in `Dawn-Auth`: the proof over this call's signing record and the grants relied on; absent or malformed is `unverified` |

The signing record of a socket call is the one HTTP.md defines, with `op`,
`params` and the body exactly as the envelope carries them; the same bytes are
signed on either binding, so a client signs once per call whichever transport
it uses.

```json
{"id": "7", "status": "created", "contract": "2026-09-19", "result": {…}}
{"id": "7", "status": "error", "contract": "2026-09-19", "error": {"error": "denied", "message": "…"}}
```

Every reply carries `contract`, the surface's status-line date; the
subprotocol carries the service name. The operational routes of
[OPERATIONS.md](../../ulab-standard/OPERATIONS.md) are not messages: `livez`,
`health`, `describe` and `describe_file` are served over HTTP only, and naming
one here is refused with `invalid`.

| `status` | Meaning | HTTP equivalent |
|---|---|---|
| `created` | an accepted publishing or provisioning verb | `201` |
| `ok` | a read, or a replayed verb (`replayed` is in the result) | `200` |
| `error` | a refusal; `error` carries the same envelope HTTP returns as a body, an internal failure (`internal`) or a transitional `not_implemented` included | `4xx`, `5xx` |

## Subscription frames

`subscribe` and `unsubscribe` are ordinary calls with ordinary replies. What is
new is that the service also writes frames **no call asked for**. A frame with
a `subscription` member and no `id` is one of these; a client that never
subscribes never receives one.

```json
{"subscription":"sub_…","contract":"2026-09-19","event":{"space":{…},"cutoff":41}}
{"subscription":"sub_…","contract":"2026-09-19","gap":{"space":{…}}}
{"subscription":"sub_…","contract":"2026-09-19","closed":{"reason":"expired","delivered":308}}
```

| Member | Meaning |
|---|---|
| `event` | one journal's advance: the `space` with its path, and the committed `cutoff` it is now at. A cursor, never the fact: read `(your last position, cutoff]` with `entries`. Advances of one journal supersede each other, so at most one is pending per journal |
| `gap` | the scope: an advance arrived while the subscription's pending table was full, so which journal moved was not kept. Re-read the scope |
| `closed` | the subscription's last frame, with its `reason` (`expired`, `unsubscribed`, `shutdown`, `scope_unavailable`) and how many events it delivered. No further frame names it |

Each event for one journal names a cutoff later than the last event for that
journal; across journals no order is promised, because Journal has no total
order to promise one from. These frames carry no `id` and no `status`, are
never replies, and are never counted against `MaxSocketInFlight` — a
subscription is not a call in flight. A client correlates them by
`subscription`, and treats an unknown `subscription` as a frame for one it has
already closed and may discard.

A subscription ends with its connection, with no notice to anyone. The next
connection opens a fresh one and reads the scope's journals at their current
cutoffs — the same read it made on first sight — because the subscriber owns
its positions and the service keeps no replay log.

## Ordering

Calls are processed concurrently. **Replies may arrive in any order**; a client
correlates by `id` and must not assume the order it sent. Writes to one journal
are still serialized by Journal, so the ordering guarantees of the surface are
unchanged. At most **32 calls** may be in flight on one connection
(`MaxSocketInFlight`). The 33rd is refused with `invalid` and the message
`connection has too many calls in flight`; the SDK keeps within the bound and
never sees that refusal. Replies are written in completion order under one
writer per connection; a client that stops reading stalls only its own
connection's replies, and a write that does not complete within 30 seconds
drops that reply.

## Closing

| Code | Sent when |
|---|---|
| `1000` | the client closed normally; the service echoes |
| `1001` | the service is shutting down; in-flight calls finish first, within the ten-second grace |
| `1002` | the subprotocol was not offered |
| `1003` | a binary frame arrived |
| `1007` | a text frame was not an envelope with an id |
| `1009` | a frame exceeded the read limit |

After a lost connection a client learns nothing about calls in flight. It
resends reads freely, and resends a publishing verb with the same operation ID
and a fresh proof: the reply is either the original result with
`replayed: true` or an ordinary evaluation. Nothing on the service side needs
reconnecting to; the next connection is a new transport for the same surface.

## Operations

Every row carried here holds the refusals, coordinate rules, record
provenance, snapshots and recovery of [SURFACE.md](SURFACE.md) exactly as
over HTTP; the [requirements](../conformance/requirements/README.md) run
every group over both bindings. Every row of the table marked `ws` is carried; the messages are `resolve`,
`children`, `entries`, `get`, `snapshot`, `publish`, `create_artifact`,
`declare`, `compose`, `apply`, `query`, `explain`, `create_project`, `provision_worker`,
`retire_worker`, `create_campaign`, `assign`, `export`, `subscribe` and
`unsubscribe`. The last two are carried **here alone**: they have no HTTP
route, because the server-initiated frame they cause is what this binding has
and HTTP does not. Their parameters,
types and refusals are those of [operations.json](operations.json) and are not
repeated here; the bodies are identical to the HTTP binding's.
