# ADR-048: WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape *Ported from alknet ADR-048 (WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape); re-targeted to alkhttp.* ## Status Accepted > **Reconciliation note (2026-08-29):** the bidirectionality this ADR > promises for the WS path (§4 — the hub calling browser-registered ops > through the connection-local Layer 2 overlay) is **decided design, > not a v1 implementation commitment**. Today the WS path installs > channel 0 only; no data-channel open path and no browser-side > registration wiring exists (review-001 finding WS-03). The cut and > its rationale are recorded in > [OQ-05](../open-questions.md); the native-session contract below > stands as what the deferred wiring must satisfy. ## Status amendment (alkhttp port) - **The WS path carries the channels session** (alkhttp ADR-067): a browser WS connection is demultiplexed by the channels protocol — 8-byte chunk headers per alkcall ADR-034/ADR-035 — rather than being a bare `EventEnvelope` stream. **Channel 0 is pre-negotiated as `alk/call`** (alkcall ADR-036) and carries the native call-protocol session **exactly as this ADR describes** — the dispatch loop, the overlay rules, and the browsers-not-peers property all stand unchanged; they apply to channel 0. - **Framing on channel 0 is length-prefixed JSON** (alkcall's frame format, alkcall ADR-014) **inside the 8-byte chunk header** — **not** one-envelope-per-WS-message. The WS message boundary carries chunks, not envelopes; see alkhttp ADR-067 §framing. Sentences below that speak of "one `EventEnvelope` = one binary WS message" are the original 2026 framing, retained as decision history; the operative framing is the channels-chunk model just described. - **The default WS upgrade path is `/alk/channels`** (was `/alknet/call` in the alknet original — renamed per the alkcall ADR-004 `alk/` ALPN convention and per alkhttp ADR-067's channels session). ## Context alkhttp ADR-044 (Accepted) removed h3/WebTransport from this crate's scope (see alkhttp ADR-069; the alknet record deferred it) and committed WebSocket as the browser bidirectional path: a browser upgrades an HTTP/1.1 or HTTP/2 request to WebSocket and the resulting full-duplex WS connection carries the call protocol's `EventEnvelope` frames (in the current wire model: on channel 0, as length-prefixed JSON inside channels chunks). alkhttp ADR-044 §1 established that the call protocol's `call.requested`/`call.responded`/`call.completed`/`call.aborted` exchange "works over WebSocket with no protocol change — the same `Dispatcher`, the same `PendingRequestMap`, the same correlation by request ID." alkhttp ADR-044 §1 also established *what shape the WS session carries* — the native `EventEnvelope` call-protocol session — but it does so as part of a larger argument about why WebTransport isn't required, not as a crisp rule an implementer is told not to violate. Two facts make the distinction worth its own explicit decision record: 1. **The HTTP surface has a deliberate, well-documented invoke contract: the `to_openapi` gateway pattern** (alkhttp ADR-042, alkhttp ADR-047). The gateway is 5 fixed endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`), where `/call` takes `{ "operation": "/fs/readFile", "input": {...} }` and invokes through `OperationRegistry::invoke()`. It is a well-shaped, simple contract. An implementer writing the WS handler could plausibly ask: "should the WS path expose the same 5-endpoint gateway shape, so a WS client looks like an HTTP client?" That is a reasonable question, and the answer is no — but the answer is currently implicit in alkhttp ADR-044's framing, not stated as a rule. 2. **The two surfaces serve different architectural roles, and that difference is load-bearing.** The HTTP gateway is, by HTTP's nature, a *one-directional projection* — client initiates, server responds (see the alkhttp server spec, §"One-directional projection"). The whole reason WebSocket exists in this architecture is to *restore the call protocol's native bidirectionality for browsers* (alkhttp ADR-044 §4): a WS connection is full-duplex, so both sides can initiate `call.requested` frames. Putting the gateway's one-directional shape on WS re-introduces the one-directional limitation that WS exists to fix. The two surfaces are not interchangeable; they are deliberately different tools for deliberately different jobs. ### The two invoke contracts, contrasted | Aspect | HTTP gateway (`/call`, alkhttp ADR-042/047) | WS native session (this ADR) | |---------|--------------------------------------|------------------------------| | Direction | One-directional (client→server calls only) | Bidirectional (either side can `call.requested`) | | Wire unit | HTTP request/response | Channels chunk (8-byte header, alkcall ADR-034/035); channel 0 payload = length-prefixed-JSON `EventEnvelope` (alkcall ADR-014) | | Invoke shape | `POST /call` with `{ "operation": "/fs/readFile", "input": {...} }` | `call.requested` event with `{ operation, input }` payload (the call protocol's native shape) | | Discovery | `GET /search` (gateway endpoint) | `services/list` as an ordinary call-protocol op | | Schema | `GET /schema` (gateway endpoint) | `services/schema` as an ordinary call-protocol op | | Streaming | `POST /subscribe` (SSE frames) | `call.responded` events as channel-0 frames (no SSE) | | Dispatcher | axum route handler → `OperationRegistry::invoke()` | shared `Dispatcher` (alkcall ADR-015, stream-agnostic) | | Multiplexing | HTTP/2 native; HTTP/1.1 sequential | Channels channel IDs (alkcall ADR-034/035) + request ID (alkcall ADR-015) | The WS row is the call protocol's own native session, with WebSocket as the transport instead of QUIC (carried on channel 0 of the channels session per alkcall ADR-036). The HTTP row is a projection of that session into HTTP's one-directional shape, with the gateway as the deliberate interface for clients that only speak HTTP. ### Why the gateway shape is wrong on WS Three concrete reasons: 1. **It duplicates the native invoke path with a lossier one.** The call protocol's `call.requested` event *is* the invoke primitive; the gateway's `/call` is that primitive wrapped in an HTTP envelope. On WS, the envelope is unnecessary — there is no HTTP request/response cycle to fit into. A gateway-on-WS would be a translation layer translating the call protocol to itself, losing bidirectionality in the process. 2. **It loses the per-caller filtering property the native session already has.** The gateway's `/search` exists to give HTTP clients the `AccessControl::check(identity)`-filtered discovery that the call protocol provides natively via `services/list`. On WS, `services/list` is already a call-protocol op the browser can call directly — the filtering is already there. A gateway-on-WS re-implements a filtering property the native session already provides. 3. **It breaks the symmetry with the QUIC path.** The `alk/call` QUIC path (alkcall ADR-015, alkcall ADR-022) is the native `EventEnvelope` session over QUIC bidirectional streams. WS is the same session over WS messages (via the channels session's channel 0 — alkcall ADR-036). Making the WS path different from the QUIC path (by putting the gateway on WS) creates two browser-reachable invoke contracts for no architectural reason — the QUIC path is the reference, and WS should mirror it, not diverge from it. ### The prior art is already native-session-shaped The `@alkdev/pubsub` WebSocket client/server (`event-target-websocket-client.ts`, `event-target-websocket-server.ts`) — the working prior art alkhttp ADR-044 cites as the reason the WS path is cheap — already carries the `EventEnvelope { type, id, payload }` shape over WS binary messages, with no gateway-style wrapping. The call protocol's `EventEnvelope` was derived from the pubsub envelope (refined with typed event names and structured payloads); the delta is small and well-defined (see the alkcall crate docs, §"Transport agnosticism"). A browser/Node WS client derived from the pubsub prior art speaks the native session shape, not a gateway shape. The gateway-on-WS variant would require *un-translating* the pubsub client's native-session shape into the gateway's `{ operation, input }` shape — work that has no payoff because the native shape is what both the QUIC path and the pubsub prior art use. *(In the current wire model, the client additionally speaks the channels chunk framing around those envelopes — alkcall ADR-034/035 via alkhttp ADR-067.)* ## Decision ### 1. A WebSocket connection is a native `EventEnvelope` call-protocol session, not the HTTP gateway shape The WS handler on `HttpAdapter` hands the WS message stream to the call protocol's shared `Dispatcher` — the same dispatch loop the call adapter uses for `alk/call` QUIC connections (alkcall ADR-015, stream-agnostic correlation; a WS message stream is another `BiStream`-satisfying transport). Concretely in alkhttp (per alkhttp ADR-067): the WS handler demultiplexes the 8-byte chunk framing of the channels protocol (alkcall ADR-034/ADR-035); **channel 0 is pre-negotiated as `alk/call`** (alkcall ADR-036) and its chunk payloads are `EventEnvelope` frames in alkcall's length-prefixed JSON frame format (alkcall ADR-014). The browser writes `EventEnvelope` frames as channel-0 chunks; the handler reads them and dispatches via `OperationRegistry::invoke()`. Responses (`call.responded`, `call.error`, `call.completed`, `call.aborted`) are written back as channel-0 chunks. The `to_openapi` gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe` — alkhttp ADR-042, alkhttp ADR-047) **do not appear on the WebSocket path**. They are the HTTP one-directional projection's invoke contract; WS carries the call protocol's own native session, which is a different (and richer) thing. ### 2. Discovery and schema are call-protocol ops, not WS-specific endpoints The browser calls `services/list` and `services/schema` as ordinary `call.requested` events over the WS connection (channel 0). They are call-protocol operations, not WS endpoints. There is no `/search` or `/schema` on WS — those are the HTTP gateway's names for the same discovery primitives. The filtering the gateway provides via `AccessControl::check(identity)`-filtered `/search` (alkhttp ADR-042 §3) is provided on the WS path by the same mechanism the call protocol uses everywhere: `services/list` is `AccessControl`-filtered natively (see the alkcall crate docs, client-and-adapters, §"services/list"). No WS-specific discovery surface exists or is needed. ### 3. Subscriptions project as native `call.responded` events, not SSE A `Subscription` operation invoked over WS streams `call.responded` events as channel-0 frames directly — no SSE `data:` framing (that is the `h2`/`http/1.1` projection for `/subscribe`, per the streaming handler decision, alkhttp ADR-049; on WS it is unnecessary because WS is already a framed full-duplex channel). `call.completed` closes the stream; `call.aborted` closes it with an error frame. This is the native streaming projection for the WS path, mirroring how subscriptions work on the QUIC path. ### 4. Bidirectionality is native and unchanged from the QUIC path The WS call-protocol session inherits the call protocol's native bidirectionality (alknet ADR-043 §2, transferred to WebSocket per alkhttp ADR-044 §3): both sides can send `call.requested` frames. The browser calls operations on the hub; the hub can call operations registered on the browser's side, over the same session, using the same `PendingRequestMap` and `EventEnvelope` framing as `alk/call`. The browser case where the client registers no operations of its own is the common case — the server→client call direction is unused because the browser has nothing to call. That is a use-case scoping, not an architectural limitation. ### 5. This is a clarifying decision, not a new one alkhttp ADR-044 §1 commits the native-session shape ("the call protocol's framing fits the WebSocket path cleanly ... the same `Dispatcher`, the same `PendingRequestMap`, the same correlation by request ID"). This ADR does not change that decision; it makes the implication an explicit, implementer-visible rule: **the WS path is the native session, and the gateway shape is deliberately not applied to it.** An implementer reading alkhttp ADR-044 alone could plausibly ask "should the WS path expose the gateway endpoints too?" — this ADR's job is to make the answer discoverable as a decision record, not implicit in framing. ## Consequences **Positive:** - One invoke model for the call protocol, regardless of transport. The QUIC path and the WS path run the same `EventEnvelope` session through the same `Dispatcher` (on the WS path, via channel 0 of the channels session — alkhttp ADR-067); the HTTP gateway is the one-directional projection for clients that only speak HTTP. An implementer building the WS handler reuses the `Dispatcher` and `OperationRegistry::invoke()` dispatch path verbatim — no WS-specific routing, no WS-specific discovery surface, no second invoke contract to design or maintain. - The `@alkdev/pubsub`/`@alkdev/operations` TypeScript clients sync to the call protocol with no translation layer: their `EventEnvelope` shape is already the native session shape, and the call protocol's envelope is a refined superset of the pubsub envelope (alkhttp ADR-044 §"Concrete prior art"). The gateway-on-WS variant would have required un-translating the pubsub client's native-session shape; this decision avoids that un-translation entirely. *(The clients also speak the channels chunk layer around those envelopes — alkcall ADR-034/035.)* - Per-caller `AccessControl`-filtered discovery is already a property of the native session (`services/list`). No WS-specific filtering surface to build or document; the call protocol's authorization model applies unchanged. - The browser is a bidirectional call target during a live session, not a peer-graph member (alkhttp ADR-044 §5, alkhttp ADR-034 §4). The native session shape is what makes this clean: the browser gets bidirectional call capability through the connection-local Layer 2 overlay (alkcall ADR-019) without peer-graph membership, and the gateway shape would not have changed this — but it would have made the WS path diverge from the QUIC path for no benefit. **Negative:** - A WS client cannot use the gateway's `{ "operation": ..., "input": ... }` body shape — it must speak the call protocol's native `call.requested` event (inside the channels chunk framing). This is honest (the WS path *is* the call protocol), but a developer who learned the gateway shape from the HTTP surface must learn the `EventEnvelope` shape (plus the chunk framing) for WS. The pubsub prior art and the `@alkdev/operations` TypeScript client already speak the envelope shape, so the delta is small for the primary consumer — but it is a real difference from the HTTP gateway's simpler `{ operation, input }` invoke body. - The 5 gateway endpoint names (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`) are HTTP-specific and do not carry over to WS. A deployment documenting its surface for both HTTP and WS clients documents two invoke shapes (the gateway for HTTP; the native session for WS). This is the cost of using the right tool for each transport instead of forcing one shape onto both. ## Reversal This ADR clarifies a decision alkhttp ADR-044 already committed (§1 describes the native session; this ADR makes that the explicit, implementer-visible rule). The reversal posture is therefore alkhttp ADR-044's, not a separate one: the WS path itself is not deferred (it is the browser path), and the native-session-not-gateway choice is a clarification of what that path carries — reversing it would mean adopting the gateway shape on WS, which would re-introduce the one-directional limitation WS exists to fix (§Context reason 1). The original text's realistic reversal path — "WebTransport revives and adds a second browser bidirectional path" — is an alknet posture and does not apply to this crate: WebTransport is removed from alkhttp scope entirely (alkhttp ADR-069), and the ALPN-stream-proxy (alknet ADR-040) is an alknet record, not ported here. If a second browser bidirectional transport ever exists, it is an alknet transport concern fronting the stable HTTP surface this crate publishes; this ADR's rule (WS = native session on the channels path, not gateway) is unaffected — the gateway shape stays HTTP-only regardless of how many browser bidirectional transports exist. ## Assumptions 1. **The call protocol's `EventEnvelope` framing fits the WebSocket path cleanly.** In the original 2026 framing: an `EventEnvelope` is a self-delimited JSON object; one envelope per WS binary message. In the current wire model (alkhttp ADR-067): the WS message boundary carries channels chunks (8-byte header, alkcall ADR-034/ADR-035), and channel 0 — pre-negotiated as `alk/call` (alkcall ADR-036) — carries `EventEnvelope` frames as length-prefixed JSON (alkcall ADR-014's frame format) inside the chunk payload. The load-bearing property — self-delimited frames, no streaming deserializer across frame boundaries — is unchanged. This is verified by prior art: the `@alkdev/pubsub` WebSocket client/server carries the same `{ type, id, payload }` envelope over WS binary messages. 2. **The shared `Dispatcher` runs over the WS path unchanged.** alkcall ADR-015 commits stream-agnostic correlation; a WS message stream is another `BiStream`-satisfying transport (per alkcall ADR-038, the channels `ChannelConnection` is itself a `BiStreamSource`). The `Dispatcher` and `PendingRequestMap` are transport-agnostic; only the connection-establishment half differs (WS upgrade handler vs QUIC accept/dial). 3. **The primary WS consumer is a browser or Node client derived from the `@alkdev/pubsub`/`@alkdev/operations` prior art.** That client already speaks the native `EventEnvelope` shape (now wrapped in the channels chunk framing). The gateway's simpler `{ operation, input }` body shape is the HTTP path's affordance for clients that only speak HTTP; a client that has chosen WS has already opted into the call protocol's native framing. 4. **`services/list` and `services/schema` are sufficient discovery for the WS path.** They are `AccessControl`-filtered (per-caller) and return the full `OperationSpec` respectively. The gateway's `/search` and `/schema` are HTTP-shaped names for these same primitives; on WS the primitives apply directly. No WS-specific discovery surface is needed. ## References - alkhttp ADR-067 — **the amendment that defines the current WS wire model**: the WS session carries the channels protocol (8-byte chunk multiplexing); channel 0 is pre-negotiated `alk/call` and carries the native call-protocol session described here. - alkcall ADR-034 / alkcall ADR-035 — the channels wire format (8-byte chunk header; pure channel multiplexing). - alkcall ADR-036 — channel 0 is pre-negotiated `alk/call`. - alkcall ADR-014 — the call protocol's hand-rolled `EventEnvelope` framing (length-prefixed JSON); the frame format carried in channel-0 chunk payloads. - alkcall ADR-015 — call-protocol stream model; stream-agnostic correlation (`Dispatcher`/`PendingRequestMap`); a WS message stream is another `BiStream`-satisfying transport. - alkcall ADR-038 — `ChannelConnection` as a `BiStreamSource`; the channels-session side of the stream-agnostic claim. - alkcall ADR-022 §5 — `to_*` adapters are projections that consume the registry; WS is not a `to_*` adapter (it carries the native session, it doesn't project it). - alkcall ADR-019 — Layer 2 per-connection overlay where browser-registered ops (if any) land. - alkhttp [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by alkhttp ADR-044 §5) — browsers are not peers; the connection-local overlay gives the browser bidirectional-call capability without peer-graph membership. - alkhttp ADR-049 — the SSE projection for `/subscribe` (the HTTP one-directional streaming path; on WS, subscriptions project as native `call.responded` events, no SSE). - alkhttp ADR-042 — the gateway pattern this ADR clarifies is HTTP-only. - alknet ADR-043 §2/§3 — bidirectionality and the no-`PeerId` connection-local overlay, transferred to WebSocket per alkhttp ADR-044 §3 *(alknet record; not ported to alkhttp)*. - alkhttp [ADR-044](044-defer-webtransport-browsers-use-websocket.md) — the ADR that committed WS as the browser path; this ADR clarifies the shape of what it committed (§1 implies the native session; this ADR makes it an explicit rule). - alkhttp ADR-047 — the gateway as the sole HTTP invoke path (the HTTP-only contract this ADR clarifies does not extend to WS). - alkhttp ADR-001 / ADR-002 — ALPN-based dispatch; `HttpAdapter` as the `ProtocolHandler` for `h2`/`http/1.1`; the WS upgrade rides the HTTP surface. - alkhttp ADR-069 — WebTransport removed from alkhttp scope (context for the reversal posture above). - The alkcall crate docs — call-protocol spec (§"Transport agnosticism") and client-and-adapters spec (§"services/list"); the old relative links into the alknet spec tree became textual references to the alkcall crate's own documentation.