Files
alkhttp/docs/architecture/decisions/048-websocket-native-session-not-gateway.md
T
glm-5.3-flash 802d94ec07 docs(review 007 Unit 2): WS-31 discovery-shadowing note + record corrections
- WS-31: websocket.md §"Data channels for browsers" + ADR-067's landed
  note record that WS-session discovery is the bootstrap set — the
  hook's bootstrap `services/*` registrations overwrite a
  base-registry `services/*` registration on the WS path by design
  (a deployment's custom `services/list` is shadowed on WS sessions
  only).
- ADR-048's landed note: correction + completion — the WS-26
  retention sentence was aspirational at the landed commit (WS-28) and
  is now real; the UP-02 posture's override half is now an explicit
  surface (`with_ws_op_register_acl` / `OpRegisterAcl`), with the
  note that `ChannelsPolicy` could not carry an op ACL.
- ADR-067's landed note: review-007 notes (WS-28 fix + gate, WS-29
  surface, WS-31 record).
- OQ-05 resolution: the retention claim carries the WS-28 correction.
- review-006 UP-02 log + WS-26 paragraph: corrections marking what the
  pre-fix tree did not have, with the landed remediation named.
- review-002 WS-17: the "bounded at 64 sessions" claim corrected —
  the bare-registry semaphore was per-request and bounded nothing;
  `SessionSlots` is the shared-cap surface.
- review-007 status: open for remediation → remediated, with the
  decisions taken (both "implement" options) and the gate names.

Verification: cargo test 454 passed / 0 failed; cargo doc --no-deps
clean (6 pre-existing warnings, identical at baseline).

Review: docs/reviews/007-ws-data-channel-surface-review.md
2026-09-05 05:45:31 +00:00

426 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
>
> **Re-point (2026-09-03):** the two alkcall mechanisms this wiring
> composes on now exist. alkcall review 004 Units 13 landed the
> per-session fork as the dispatch registry (alkcall ADR-047 §4
> amendment #2), the full-duplex serving loop
> (`Dispatcher::serve_single_stream`, opt-in via
> `ChannelClient::from_connection_with_serving`), and the
> `op/register` bootstrap op (alkcall ADR-022 amendment 2026-09-03 —
> the bootstrap-op set each side may serve). The browser
> bidirectionality promise is no longer standing on unmechanized
> commitments: the deferred WS wiring (OQ-05, alkhttp review 003
> Unit 2) composes on those alkcall ADRs.
>
> **Updated (2026-09-04):** alkcall review 005 remediated the landed
> mechanisms (serving-loop concurrency, `op/register` collision
> policy, spec round-trip completeness) and alkcall 0.3.0 shipped
> them; alkhttp now consumes 0.3. The ADR-022 collision policy
> (2026-09-04 sub-amendment) binds here too: a peer-announced op may
> replace other peer-announced ops but never the serving side's own
> registrations — the WS session's `op/register` handler gates on the
> session fork.
>
> **Landed (2026-09-04, review 006 Unit 2+3):** the WS wiring this
> note tracks is in — the hook forks the base registry per session,
> registers the channel ops + openables + bootstrap discovery +
> `op/register` on the fork, and dispatches over it; the session
> retains its live `Arc<CallConnection>` in `WsSessions` (the hub's
> reach-the-browser handle). §4's hub→browser call direction now has
> its object: announced ops land in the connection overlay via
> `op/register`, and the hub composes them through the connection
> handle (gate:
> `op_register_served_per_session_and_collision_is_already_exists`).
> The `op/register` ACL posture on this surface: registered with
> `AccessControl::default()` (the SRV-10 permissive-crate-default
> precedent); deployments gate via a stricter `ChannelsPolicy`
> passed through the hook (review 006 UP-02 decision).
>
> **Review-007 correction + completion (2026-09-05):** the retention
> sentence above was, at the time of the landed note, aspirational —
> review 007 WS-28 found the `ConnectionGuard` dropping at the end of
> its `if let` block (the handle visible for microseconds) and
> remediation bound it in the channel-0 task's frame; the retention is
> now real, gated by
> `live_connections_visible_mid_session_and_drain_after_teardown`.
> The ACL posture's second half is likewise now real as an explicit
> surface (the `ChannelsPolicy` object could not carry an op ACL):
> `HttpAdapter::with_ws_op_register_acl` / the `OpRegisterAcl`
> request extension set the `op/register` op's `AccessControl` —
> default unchanged (`AccessControl::default()`, any authenticated
> peer may announce); a deployment restricting which authenticated
> peers may announce threads a stricter value. The UP-02 posture
> stands; only the override surface it named now exists.
## 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.