--- status: draft last_updated: 2026-09-04 --- # 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. Wired 2026-09-04 (review 006 Unit 2+3): the hook registers the deployment's openables — `HttpAdapter::with_ws_openable_alpns` (default: none) or the `OpenableAlpns` request-extension fallback — plus the generic channel ops, the bootstrap discovery set, and `op/register`, on the per-session fork (alkcall ADR-047 §4 amendment #2). 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. #### Idle-read timeout: progress semantics + the no-keepalive decision (WS-01, WS-13) The WS pumps carry an **idle-read eviction knob** (`HttpAdapter::with_ws_idle_timeout`, default `DEFAULT_WS_IDLE_TIMEOUT` = 60 s, disable with `None`). Its semantics, decided in review-002 (WS-13), are **progress-based, deliberately strict**: - The deadline resets on **demux progress** — bytes actually forwarded into the byte stream that *complete* an inbound chunk (a full 8-byte header + its declared payload). WS message arrival resets nothing. - Therefore the dribble stall (declare a chunk, deliver its payload a byte per message) hits the deadline and is evicted with a `1001` (GoingAway) close — even though messages keep arriving — while a peer delivering complete chunks, however slowly per message, re-arms the window with each chunk and survives. - **There is no WS ping/pong keepalive, on purpose.** A keepalive rescues app-silence only by re-arming the deadline — and a pong is indistinguishable from the dribble's almost-invisible arrivals, so adding one would reopen the stall it exists to seal. The recorded decision (module doc of `src/websocket/byte_adapter.rs`, option (b) of the two WS-13 alternatives): **60 s of no chunk progress is an intentional eviction line, even for a silent subscription.** - A deployment running long-lived silent-but-alive sessions (quiet subscriptions that outlast the window) disables the knob with `with_ws_idle_timeout(None)` and leans on the remaining levers: the session registry's forced-eviction ([`WsSessions::abort`](#connection-local-overlay)), the inbound caps (WS-06), and the write-side caps (WS-04/05/06). This is the same deployment posture as `from_wss`'s drop monitor ([ADR-070](decisions/070-from-wss-consumer-adapter.md)): the idle knob bounds *demux parking*, not app liveness. - **Data channels sharpen this knob's bite** (surfaced with the data-channel wiring, review 006): a silent-but-alive *data channel* (an idle SSH-style session, an open-but-quiet tunnel) forwards no chunks, so the deadline runs out and the whole WS session is evicted with `1001` — exactly the same semantics as before the wiring (this is not a new rule), but the 60 s default now hits more often because sessions live longer and carry quiet channels. A deployment serving long-lived interactive channels should set `with_ws_idle_timeout(None)` at assembly and lean on the same levers as above; the idle knob remains a demux-parking bound, not an application keepalive. - Layered note (the WS-13/FWD-15 interaction recorded here): the *HTTP* SSE path (`/subscribe`) sends server-side keep-alive comment frames every 15 s — see [http-server.md](http-server.md)) — because its idle enemy is LB/proxy timeouts, and its keep-alive does not reset any progress deadline, it cannot reopen the WS-13 hole. The two live at different layers: SSE keep-alive fights transport fires; the WS idle knob bounds demux parking. Both documented in the same pass per review-002 Unit-2 sequencing. ### 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. The openable set is declared with `HttpAdapter::with_ws_openable_alpns` (each `OpenableAlpn { spec, open_handler, establisher, establisher_timeout }`; the ALPN-specific handlers and establishers stay in the ALPN crates — alkhttp ferries the registrations). The establisher (alkcall 0.5.0 / ADR-049) is the awaited establishment phase of the open op: `None` (the default) keeps the pre-0.5 shape — the open replies as soon as the pump handler spawns; `Some` semantically-validating hook dials/prepares the backend before the reply, and a bounded failure resolves `channel:open_failed` with `details.reason` (the channel never exists consumer-side). The per-registration timeout override bounds the establisher when the dispatch carries no deadline. The establisher's `Establishment.plan` payload (alkcall 0.6.0 / ADR-049 amendment 2 — a typed-opaque `ChannelPlan`) is threaded process-locally to the pump handler's second parameter: the establisher and the handler agree on the concrete type, so ALPN crates dial/allocate in the establisher and deliver the live handle to their handler without a side-channel handoff. Data-plane handlers that pump a channel stream against a peer's split halves can use alkcall's `channels::pump_bidi` helper (alkcall 0.6.0 / ADR-050). The `OpenableAlpns` request-extension fallback is available for bare-registry/custom upgrade routes. Cap policy is the `ChannelsPolicy` extension (one instance consulted by both the open wrappers and the demux teardown). Peer-announced ops (`op/register`) land in the connection-local overlay; discovery: `services/list` (the session's own surface) and `services/list-peers` (peer-announced ops, alkcall 0.3.1). E2E gates for the whole flow live in `tests/ws_upgrade_session.rs`. WS-session discovery is the **bootstrap set**: the hook registers alkcall's `services/list` / `services/schema` / `services/list-peers` closed over the session's fork (so the listing sees the session's own openables — the F-06 shape), which **shadows by design** any `services/*` operation the deployment registered on its base registry (`register` inserts by name, so the per-session fork's bootstrap registrations overwrite the deployment's on the WS path). A deployment with a custom `services/list` keeps it on every other transport and gets alkcall's bootstrap listing on WS sessions — recorded here so the asymmetry is a decision, not a surprise (review 007 WS-31). ### 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)