--- status: draft last_updated: 2026-08-27 --- # WebSocket — the Browser Bidirectional Path (Channels over WS) WebSocket is the browser bidirectional path to the call protocol and the channels protocol. A WS session carries the **channels protocol** ([ADR-067](decisions/067-websocket-carries-channels.md)): the 8-byte chunk header multiplexes N logical channels over the WS binary message stream; **channel 0 is pre-negotiated as `alk/call`** and carries the native call-protocol session, dispatched by the shared `Dispatcher`. This supersedes the alknet design where WS carried one bare `EventEnvelope` per message. A WS session is a **native channels session, not the HTTP gateway shape** — the gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`, `/publish`) are the HTTP one-directional projection and **do not appear on the WebSocket path** ([ADR-048](decisions/048-websocket-native-session-not-gateway.md), as amended by ADR-067). Discovery is via `services/list`/`services/schema` as call-protocol operations on channel 0. ## What The WebSocket path is an axum WS upgrade handler on the same `HttpAdapter` that serves `h2`/`http/1.1` ([http-server.md](http-server.md)). A browser (or any WS client — Node, a native app, the `from_wss` consumer ([ADR-070](decisions/070-from-wss-consumer-adapter.md)) in reverse) opens an HTTP/1.1 or HTTP/2 request to the upgrade path, authenticates by bearer token on the upgrade request, and the resulting full-duplex WS connection is a channels connection: the 8-byte chunk format (alkcall ADR-034) runs over it exactly as over TCP+TLS. ## Why WebSocket is the HTTP-family transport that restores the alk protocols' native bidirectionality for browsers — HTTP/1.1 + HTTP/2 are request/response (a one-directional projection of the call protocol), while WS is a full-duplex, long-lived, framed-message channel. Carrying the **channels protocol** (rather than a bare envelope stream, the alknet choice) makes a browser session structurally identical to a Rust in-line channels session (TCP+TLS): one session shape, one dispatch path, data channels included. The decision is [ADR-067](decisions/067-websocket-carries-channels.md); the decision that the WS path carries the native session rather than the gateway shape is [ADR-048](decisions/048-websocket-native-session-not-gateway.md). ### The consumer-side mirror: `from_wss` The same session serves the outbound direction: a Rust process dials a remote node's WSS endpoint and consumes its operations ([ADR-070](decisions/070-from-wss-consumer-adapter.md)). Server upgrade and consumer dial share the WS↔byte-stream adapter; the session halves are alkcall's (`ChannelsAdapter` accept path vs `ChannelClient` consumer path). ## Architecture ### The WS upgrade handler The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum route on `HttpAdapter`'s router. The handler: 1. Receives the HTTP upgrade request (axum's `WebSocketUpgrade` extractor). 2. Resolves the caller's identity from the `Authorization: Bearer` header via `identity_provider.resolve_from_token(&AuthToken { raw: token_bytes })` (`AuthToken` is in `alkcall::core::auth`) — the same auth path as any HTTP request ([http-server.md](http-server.md) §"Auth"). The upgrade is rejected (`401`) if no token is present; insufficient scopes for any op the browser later calls surface as `FORBIDDEN` at call time, not at upgrade time (the upgrade doesn't know which ops the browser will call). 3. Upgrades to WebSocket (axum's `WebSocketUpgrade::on_upgrade`), producing a full-duplex `WebSocket` stream. 4. Adapts the WS message stream to a byte stream (see §"Framing" below — the adapter is this spec's core new piece). 5. Wraps the byte stream as a `Connection` (`Connection::from_bidi`, ALPN `alk/channels`). 6. Runs the channels accept path: the in-line demux loop (alkcall `ChannelsAdapter`) installs channel 0 via the `install_channel_zero` hook — constructing channel 0's `CallConnection` (identity attached) and running `Dispatcher::run_loop_single_stream` on it. 7. Data channels (1..N) route per the deployment's openable-ALPN registrations; the browser opens them via the per-ALPN open ops on channel 0 (alkcall ADR-047), the same mechanism any consumer uses. The **default upgrade path is `/alk/channels`** (was `/alknet/call` in the alknet design). The path must not collide with the reserved gateway/`/healthz`/`/openapi.json`/MCP/custom-route paths per [ADR-046](decisions/046-assembly-layer-custom-http-routes.md)'s collision rule; `/alk/channels` namespaces away from the reserved set naturally. The upgrade runs over HTTP/1.1 (RFC 6455) or HTTP/2 (extended CONNECT, RFC 8441); axum/hyper supports both, and the handler does not branch on which — the WS frame stream is the same once the upgrade completes. ### Framing: WS messages carry chunks; channel 0 carries length-prefixed envelopes The wire layering: ``` WS binary message (message boundary = transport frame) └── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes) └── payload channel 0: [len: u32 BE][EventEnvelope JSON] (call frame) channel N: handler-owned framing (opaque to channels) ``` - **WS messages are transport frames, not protocol boundaries.** The WS binary message stream is treated as a byte stream; the 8-byte chunk header is the only framing. A chunk may span WS messages and a WS message may carry chunk fragments — the adapter (below) is the seam. (In practice the write path usually emits one chunk per message, but nothing may depend on it: channel 0's frame writer issues two writes — length prefix, then body — which the mux can deliver as two mpsc payloads.) - **Channel 0's payload is the call protocol's frame format** (alkcall ADR-014): a 4-byte big-endian length prefix + UTF-8 JSON `EventEnvelope`. This is exactly the framing channel 0 uses over TCP+TLS; the WS path is not special at this layer. **Chunk ≠ frame**: a single frame may arrive as multiple chunks (the frame writer issues prefix and body as separate writes through the mux — live-confirmed in the ws-byte-adapter POC), so channel-0 consumers MUST reassemble length-prefixed frames from the channel-0 byte stream, never parse per-chunk. The request payload's operation name key is `operationId`. - **Data-channel payloads are opaque** — the handler owns its framing (alkcall ADR-035: no `stream_type` anywhere in the channels layer). - **Text WS messages are rejected** with a protocol-level close (code `1002`). All frames are binary. - The alknet design's "one envelope = one WS message, no length prefix" rule is superseded; see [ADR-048](decisions/048-websocket-native-session-not-gateway.md)'s status amendment. #### The WS ↔ byte-stream adapter The channels machinery reads and writes **bytes** (`AsyncRead` + `AsyncWrite`): the demux does `read_exact` on the 8-byte header and payload; the mux writes chunk bytes. A WebSocket is **message-oriented**: axum yields whole `Message`s, and writes are whole `Message`s. The adapter bridges the two, in both directions: - **Inbound (WS → bytes):** a background task pops WS binary messages and appends their bytes into a shared buffer; the `AsyncRead` half drains the buffer. Backpressure: the reader task awaits a bounded buffer slot before admitting the next message (bound: OQ-01). - **Outbound (bytes → WS):** the `AsyncWrite` half accumulates bytes into a pending buffer; a background task scans the pending bytes for complete chunks (8-byte header → payload length) and emits each complete chunk as one WS binary message, carrying any partial tail until its chunk completes. The adapter **parses the outgoing byte stream** to find chunk boundaries — it does not assume one write equals one chunk (verified against alkcall: `write_chunk` issues header+payload as separate writes, and channel 0's `write_frame` issues prefix+body as separate writes; each can surface as separate mux payloads). Oversized chunks (a single chunk exceeding the WS-message cap, up to `MAX_CHUNK_LEN` = 16 MiB) are split across multiple WS messages — legal, since the receiver's boundary is the chunk header, not the message. Flush semantics are an OQ-01 item. - **Close mapping:** WS close (either side) → transport EOF → the demux clears all channels (REQ-CH-02: every handler sees EOF) and channel 0's dispatch loop fails outstanding pendings with `connection closed`. `AsyncWrite::shutdown` maps to the zero-length EOF sentinel (REQ-CH-01) followed by a WS Close frame. The adapter is shared with the `from_wss` consumer path ([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one implementation, both directions. ### Dispatch: channel 0 = the shared `Dispatcher`, unchanged Channel 0's session is the alknet design's native session, verbatim: - For `call.requested`: runs `AccessControl::check(identity)` against the op's `AccessControl`, dispatches via the registry if allowed, returns `FORBIDDEN` (→ `call.error`) before the handler runs if not. - For `call.responded`/`call.completed`/`call.aborted`: correlates by `id` via the pending map (keyed by request ID, not by transport — alkcall ADR-015). - For `call.published` (initiator-side `Pub` ops): routed to the matching sink (alkcall ADR-046). - Writes response frames back through channel 0's shared writer. Peer authorization flows through `AccessControl::check` against the resolved identity — an op with `AccessControl::default()` is callable by any authenticated browser; an op with `required_scopes` only by identities whose scopes satisfy them; an op with `Visibility::Internal` is never callable from the wire (`NOT_FOUND` before ACL). This is alkcall ADR-017's model; see also [ADR-015](decisions/015-privilege-model-and-authority-context.md). ### Data channels for browsers A browser opens a data channel exactly as any channels consumer: 1. Calls the per-ALPN open op on channel 0 (`channels//sub` or `channels//pub` — alkcall ADR-047). 2. The producer side (the hub) checks the open against the registered openable ALPNs and `check_open(identity)`, allocates the `channel_id`, and returns it. 3. Chunks with that `channel_id` flow over WS messages; both sides reassemble the `BiStream`. The deployment decides which ALPNs are openable — the browser reaches exactly what a Rust consumer on an in-line connection would reach. This is what makes the WASM-SSH-client-in-a-browser use case (the one that motivated the alknet WebTransport track) workable over WS: the SSH byte stream rides a data channel. ### Bidirectionality The WS channels session inherits both protocols' native bidirectionality (alkcall ADR-015 for calls; the channels protocol is symmetric by construction): - **Calls:** both sides can send `call.requested` on channel 0. The browser calls hub ops; the hub can call browser-registered ops over the same session, same pending map, same framing. - **Channels:** the browser can open data channels (via open ops); the hub can open them toward the browser if the browser side registers openable ALPNs (a browser that is also a producer). The common case — a browser that registers no ops and opens no channels — is a use-case scoping, not an architectural limitation. ### Connection-local overlay A browser over WS has no `PeerId` on the hub's side. Any ops the browser registers land in a **connection-local Layer 2 overlay** (alkcall ADR-019; the mechanism the hub reaches via the live connection handle's `overlay_env()`). When the WS connection closes (browser closes the tab, network drops), the overlay and all its registered ops drop — no explicit deregistration. The hub reaches browser ops through the connection handle, not through `PeerRef::Specific` (the browser is not a peer — see below). ### Streaming: native `call.responded` events, no SSE A `Sub` operation invoked on channel 0 streams `call.responded` frames as channel-0 chunks — **no SSE framing**. SSE is the h2/http1.1 streaming projection (the gateway's `/subscribe` endpoint per [ADR-042](decisions/042-openapi-gateway-pattern.md)); on WS it is unnecessary because WS is already a framed full-duplex channel. The browser receives `call.responded` events one per frame, with the same `id` correlating them to the original `call.requested`; `call.completed` closes the subscription; `call.aborted` closes it with an error frame. This is identical to how subscriptions work over any in-line channels transport. A `Pub` operation (browser as initiator) publishes `call.published` frames from the browser into channel 0 — the HTTP gateway's `/publish` ([ADR-068](decisions/068-gateway-publish-endpoint.md)) has no WS equivalent because the native mechanism already exists. On WS client disconnect (the browser closes the tab mid-subscription), the session teardown detects the close and aborts in-flight subscriptions — the abort cascade runs per alkcall ADR-020. ### Browsers are not alk peers A browser over WS authenticates by bearer token, gets no `PeerId`, does not enter `PeerCompositeEnv`, and its registered ops (if any) land in the connection-local overlay. The rationale ([ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4, amended by alknet ADR-044 §5) is a load-bearing distinction: **"Peer" means an addressable node in the call-protocol peer graph** — a stable `PeerId`, reachable via `PeerRef::Specific`, whose identity is stable across reconnects. It does *not* mean "any endpoint that exchanges calls during a live session." A browser is the second thing but not the first: 1. **No stable cryptographic identity of its own.** A peer entry is anchored to fingerprints the *peer* presents and the local node pins. A browser presents a bearer token the *hub* issued — hub bookkeeping, not pinnable identity. There is nothing to put in fingerprints. 2. **Ephemeral.** Close the tab → connection dies → the connection-local overlay dies with it. A peer entry keyed to a browser would be permanently dead within seconds. 3. **Not addressable from other nodes.** `PeerRef::Specific` resolves through a peer entry; another node has no way to reach "the browser currently connected to hub-A." The hub holds that connection as a live handle, not a peer-graph entry. The browser is a **bidirectional call target during a live session**, not a **peer-graph member**; the connection-local overlay is what gives the former without the latter. ### Auth: bearer token on the upgrade request Inbound WS auth is `Authorization: Bearer ` on the HTTP upgrade request, resolved via `IdentityProvider::resolve_from_token()` — the same path as any HTTP request ([ADR-004](decisions/004-auth-as-shared-core.md); mechanism owned by alkcall ADR-003). Bearer-only is the auth mechanism; other schemes would be added as axum middleware on the upgrade route (two-way door). The resolved identity drives `AccessControl::check` on every `call.requested` the browser sends and `check_open` on every channel-open op — per-privilege filtering is free via `services/list`'s existing `AccessControl` filtering. For the consumer side (`from_wss`), the same shape applies outbound: the adapter presents the token on its dial, sourced from `Capabilities` (the no-env-vars path, [ADR-014](decisions/014-secret-material-flow-and-capability-injection.md)). ## Constraints - **The WS path is the channels session, not the gateway shape ([ADR-067](decisions/067-websocket-carries-channels.md), [ADR-048](decisions/048-websocket-native-session-not-gateway.md)).** The 6 gateway endpoints are HTTP-only and do not appear on WS. Discovery via `services/list`/`services/schema` as call-protocol ops; streaming as native frames, not SSE. - **Bearer-only auth on the upgrade request.** `Authorization: Bearer` → `resolve_from_token`. The resolved identity drives `AccessControl::check` on calls and `check_open` on channel opens. - **Browsers are not alk peers.** Bearer token, no `PeerId`, connection-local Layer 2 overlay for browser-registered ops. - **One WS binary message = one chunk; channel 0 frames are length-prefixed JSON inside the chunk payload.** Text messages are rejected. The alknet "one envelope per message" rule is superseded ([ADR-048](decisions/048-websocket-native-session-not-gateway.md) status amendment). - **The shared `Dispatcher` runs on channel 0 unchanged (alkcall ADR-015).** The dispatch half is one implementation across all in-line transports; only connection establishment differs. - **The default upgrade path is `/alk/channels`; it must not collide with reserved paths ([ADR-046](decisions/046-assembly-layer-custom-http-routes.md)).** Overridable via `extra_routes`. - **The WS↔byte-stream adapter is the single seam between axum's WS and alkcall's byte-oriented channels machinery** — shared with `from_wss`, semantics tracked in OQ-01. ## Design Decisions | Decision | ADR | Summary | |----------|-----|---------| | WS carries the channels protocol (channel 0 = `alk/call`) | [ADR-067](decisions/067-websocket-carries-channels.md) | WS = in-line channels substrate; 8-byte chunk demux; upgrade path `/alk/channels`; supersedes bare-envelope framing | | WS carries the native session, not the gateway shape | [ADR-048](decisions/048-websocket-native-session-not-gateway.md) | Channel 0 carries the native call session; gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` | | Call protocol stream model | alkcall ADR-015 | Stream-agnostic correlation; the pending map and dispatch loop run over any transport | | WS as the browser bidirectional path | [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) | Stands; WebTransport removed from alkhttp scope ([ADR-069](decisions/069-webtransport-out-of-scope.md)) | | Operation registry layering (connection-local overlay) | alkcall ADR-019 | Browser-registered ops land in a per-connection overlay that dies with the WS connection | | Browsers are not alk peers | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 | Bearer token, no `PeerId`; addressability vs bidirectionality rationale | | Abort cascade on disconnect | alkcall ADR-020 | WS close mid-subscription aborts in-flight ops, cascading to descendants | | Bearer auth via `resolve_from_token` | [ADR-004](decisions/004-auth-as-shared-core.md) | WS upgrade request credential source (same as HTTP; mechanism: alkcall ADR-003) | | Browsers require X.509 (TLS) | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | The WS upgrade runs over the same TLS as h2/http1.1; TLS provisioning is an alknet concern | | Stealth: HTTP handler on standard ALPNs serves WS upgrade | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | The WS upgrade route is on `HttpAdapter`'s default surface | | Custom routes collision rule | [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) | The WS upgrade route must not collide with reserved default-surface paths | | `from_wss` consumer adapter | [ADR-070](decisions/070-from-wss-consumer-adapter.md) | The outbound mirror: same adapter, `ChannelClient` consumer half, `wss` feature | ## Open Questions See [open-questions.md](open-questions.md) for full details. - **OQ-01** (resolved): WS ↔ byte-stream adaptation — resolved by the production adapter (`byte_adapter.rs`): inbound bounded mpsc (64 slots, backpressure), outbound chunk parser with 1 MiB message cap, flush no-op, close → EOF mapping; validated end-to-end in both directions (server upgrade path + `from_wss` client). - **OQ-04** (open): browser client library ownership — the JS/TS client speaking channels-over-WS (chunk framing, channel 0 open ops, envelope handling) is needed for browser consumers; it lives outside this crate, but the BAST chunk-header contract (alkcall `chunk-header.bast.json`) is the shared reference. ## References - [ADR-067](decisions/067-websocket-carries-channels.md) — the channels-over-WS decision (this document's specification target) - [ADR-048](decisions/048-websocket-native-session-not-gateway.md) — the native-session decision (amended by ADR-067 for framing and path) - [ADR-070](decisions/070-from-wss-consumer-adapter.md) — the consumer side - [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) — WS as the browser path (stands); [ADR-069](decisions/069-webtransport-out-of-scope.md) — WebTransport out of scope - [http-server.md](http-server.md) — the `HttpAdapter` hosting the upgrade route - The alkcall crate's `docs/architecture/` — call protocol wire format (ADR-014), stream model (ADR-015), registry layering (ADR-019), abort cascade (ADR-020), channels wire format (ADR-034/035), channel 0 pre-negotiation (ADR-036), ChannelsAdapter (ADR-039), ChannelClient (ADR-043), openable ALPNs (ADR-047); `channels-wire.md` for the chunk format and wire invariants (REQ-CH-01/02/04/05) - The alknet mono-repo's `@alkdev/pubsub` WS client/server — prior art for the browser WS path lineage (the envelope shape the call protocol refined)