Files
alkhttp/docs/architecture/decisions/048-websocket-native-session-not-gateway.md
T
glm-5.3-flash a5b294e965 docs(adrs): reconcile WS-03 browser data-channel promise (ADR-067/048)
Record the v1 cut for browser-opened data channels over WS (review-001
WS-03): the design (ADR-067) stands; only the wiring is deferred.

- open-questions.md: add OQ-05 (deferred(scope: v1 cut)) with gap
  detail and deferred scope
- ADR-067: dated status amendment + v1-cut note at the data-channel
  step; References point to OQ-05
- ADR-048: dated reconciliation note — overlay bidirectionality is
  decided design, not a v1 implementation commitment
- websocket.md: status notes on §"Data channels for browsers" and
  upgrade step 7
- task review-001-ws-data-channel-decision: completed, Summary filled

Verification: taskgraph validate (38 tasks OK); cargo doc --no-deps
(pre-existing warnings only).
2026-08-29 13:57:24 +00:00

374 lines
21 KiB
Markdown

# 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.