- 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
211 lines
10 KiB
Markdown
211 lines
10 KiB
Markdown
# ADR-067: WebSocket Carries the Channels Protocol
|
||
|
||
## Status
|
||
|
||
Accepted (amended 2026-08-29 — the data-channel half of the WS path is
|
||
deferred, not yet wired; see [OQ-05](../open-questions.md); amended
|
||
2026-09-04 — the deferred wiring landed, review 006 Units 2–3)
|
||
|
||
## Context
|
||
|
||
The alknet design (ADR-044, ADR-048 there) made the WebSocket path a
|
||
bare call-protocol session: one `EventEnvelope` JSON object per binary
|
||
WS message, handed directly to the shared `Dispatcher`. That design
|
||
predates the channels protocol — the 8-byte chunk multiplexer (alkcall
|
||
ADR-034/035) with channel 0 pre-negotiated as `alk/call` (alkcall
|
||
ADR-036) — and was specified when browsers needed only the call
|
||
protocol.
|
||
|
||
alkhttp is extracted onto alkcall, where the call protocol and the
|
||
channels protocol are one crate, one connection model, and one wire
|
||
story. Two problems remain with the bare-envelope WS design:
|
||
|
||
1. **No data channels for browsers.** A browser session over WS could
|
||
reach the call protocol but could never open a data channel (TTY,
|
||
tunnel, a WASM SSH client's transport). Every downstream protocol
|
||
crate that rides channels would need a separate browser path.
|
||
2. **Two connection shapes, one stack.** A browser WS session and a
|
||
Rust in-line channels session (TCP+TLS) would be structurally
|
||
different sessions at the dispatch layer: the browser one a raw
|
||
envelope stream, the Rust one a channels connection with a
|
||
`ChannelManager`. Hub code that wants to treat browser sessions and
|
||
Rust spokes uniformly would branch on the transport.
|
||
|
||
## Decision
|
||
|
||
**The WebSocket path carries the channels protocol, not a bare
|
||
envelope stream.** A WS session is an **in-line channels substrate**
|
||
(alkcall ADR-034 §substrate modes): the WS connection's binary message
|
||
stream is the transport; the 8-byte chunk header demultiplexes N
|
||
logical channels over it; **channel 0 is pre-negotiated as `alk/call`**
|
||
(alkcall ADR-036) and carries the native call-protocol session — the
|
||
shared `Dispatcher` runs on it unchanged.
|
||
|
||
### Upgrade path
|
||
|
||
The default WS upgrade path is **`/alk/channels`** (was `/alknet/call`
|
||
in the alknet design). The path is an axum route on the `HttpAdapter`
|
||
router, subject to the same reserved-path collision rule as any
|
||
default-surface route ([ADR-046](046-assembly-layer-custom-http-routes.md)).
|
||
|
||
### Framing: the chunk header is the boundary, not the WS message
|
||
|
||
The alknet design's "one `EventEnvelope` = one binary WS message, no
|
||
length prefix" framing is **superseded**. The WS binary message stream
|
||
is treated as a byte stream; the 8-byte chunk header is the only
|
||
framing. Call-protocol envelopes ride inside channel 0 as
|
||
length-prefixed JSON (alkcall ADR-014 frame format), the same as any
|
||
other in-line channels transport. A chunk may span WS messages and a
|
||
WS message may carry chunk fragments (in practice the write path
|
||
usually produces one chunk per message, but nothing may depend on
|
||
it — channel 0's frame writer issues two writes, prefix then body,
|
||
which can surface as two chunks).
|
||
|
||
Layering on the wire, for a call frame over WS:
|
||
|
||
```
|
||
WS binary message(s) — byte stream
|
||
└── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes)
|
||
└── payload = frame [len: u32 BE][EventEnvelope JSON] (channel 0)
|
||
```
|
||
|
||
A data channel's chunks are `chunk header + opaque payload` — the
|
||
handler (TTY, tunnel, WASM client) owns its framing inside the payload,
|
||
exactly as over TCP+TLS (alkcall ADR-035: no `stream_type`, the
|
||
handler owns its sub-stream multiplexing).
|
||
|
||
The WS↔byte-stream adaptation (how the message-oriented WS stream
|
||
presents as the byte stream the channels demux reads, and how the mux's
|
||
byte writes become WS messages) is the implementation's core piece and
|
||
its buffering semantics are tracked in OQ-01.
|
||
|
||
### Dispatch: channel 0 = the shared `Dispatcher`, unchanged
|
||
|
||
On upgrade, the handler:
|
||
|
||
1. Resolves the caller's identity from the `Authorization: Bearer`
|
||
header via `IdentityProvider::resolve_from_token()` — the same auth
|
||
path as any HTTP request ([ADR-004](004-auth-as-shared-core.md)).
|
||
No token → `401`. The resolved identity is carried on the session
|
||
for observability and `AccessControl`.
|
||
2. Wraps the WS stream as a `Connection` (`Connection::from_bidi`, ALPN
|
||
`alk/channels`).
|
||
3. Runs the channels accept path — alkcall's `ChannelsAdapter`
|
||
in-line demux loop — installing channel 0 via the
|
||
`install_channel_zero` hook: construct channel 0's `CallConnection`
|
||
and run `Dispatcher::run_loop_single_stream` on it, exactly as the
|
||
TCP+TLS in-line substrate does.
|
||
4. Data channels (1..N) are routed to whatever the deployment
|
||
registered as openable ALPNs — for a hub, the same openable ALPNs a
|
||
Rust spoke can reach. The browser opens them via the per-ALPN open
|
||
ops on channel 0 (alkcall ADR-047), the same mechanism as any
|
||
consumer.
|
||
|
||
> **v1 cut (2026-08-29):** step 4 is designed but not wired in v1 —
|
||
> today the WS upgrade path installs channel 0 only and no
|
||
> `register_openable`/data-channel dispatch path exists for browser
|
||
> sessions (review-001, WS-03). The deferral and its rationale are
|
||
> recorded in [OQ-05](../open-questions.md); the design above is the
|
||
> contract the deferred wiring implements.
|
||
>
|
||
> **Wired (2026-09-04):** step 4 landed (review 006 Unit 2+3) in the
|
||
> per-session-fork shape alkcall ADR-047 §4 amendment #2 decided: the
|
||
> `install_channel_zero` hook forks the deployment's base registry,
|
||
> registers the generic channel ops + the deployment's openables
|
||
> (`HttpAdapter::with_ws_openable_alpns`, request-extension fallback)
|
||
> + the bootstrap discovery set + `op/register` on the fork, and
|
||
> dispatches over it. Openable set default: none (channel 0 only).
|
||
> The session retains its live `Arc<CallConnection>` in `WsSessions`
|
||
> (WS-26). Gates: the six e2e scenarios in
|
||
> `tests/ws_upgrade_session.rs` + the `services/list-peers` announced-op
|
||
> discovery gate (alkcall 0.3.1).
|
||
>
|
||
> **Review-007 notes (2026-09-05):** WS-28 fixed the WS-26 retention —
|
||
> the `ConnectionGuard` now lives in the channel-0 task's frame (its
|
||
> drop removes the handle on any teardown path), verified by
|
||
> `live_connections_visible_mid_session_and_drain_after_teardown`.
|
||
> WS-29 implemented the `op/register` override surface the review-006
|
||
> UP-02 posture named: `HttpAdapter::with_ws_op_register_acl` on the
|
||
> built-in surface, the `OpRegisterAcl` request extension on
|
||
> bare-registry routes (default still `AccessControl::default()`).
|
||
> WS-31 recorded: WS-session discovery is the bootstrap set — the
|
||
> hook's bootstrap registrations overwrite a base-registry
|
||
> `services/*` registration on the WS path by design.
|
||
|
||
Everything ADR-048 says about dispatch — `call.requested` →
|
||
`Dispatcher::dispatch_requested` with `AccessControl::check` gating,
|
||
`call.responded`/`call.completed`/`call.aborted` correlated by `id` via
|
||
the pending map, text WS messages rejected with a protocol-level close
|
||
— applies to channel 0 verbatim. The only framing change is the
|
||
envelope's position in the layering (above).
|
||
|
||
### Bidirectionality, overlay, browsers-are-not-peers: unchanged
|
||
|
||
- **Both sides can initiate calls** on channel 0 (alkcall ADR-015's
|
||
stream-agnostic correlation). The browser calls hub ops; the hub can
|
||
call browser-registered ops over the same session.
|
||
- **Browser-registered ops land in the connection-local Layer 2
|
||
overlay** (alkcall ADR-019) and die when the WS connection drops.
|
||
- **Browsers are not peers** ([ADR-034](034-outgoing-only-x509-and-three-peer-roles.md)
|
||
§4): bearer token, no `PeerId`, not in the peer graph. The connection
|
||
handle, not a `PeerRef`, is how the hub reaches browser ops.
|
||
|
||
### What this gives the browser
|
||
|
||
A browser session is now structurally the same session as a Rust
|
||
in-line channels session:
|
||
|
||
- Call protocol operations (channel 0) — both directions.
|
||
- Data channels for anything the deployment registers as openable —
|
||
a WASM SSH client, a TTY, a tunnel — with no browser-specific
|
||
protocol work.
|
||
- The same discovery ops (`services/list`, `services/schema`) as any
|
||
consumer.
|
||
|
||
## Consequences
|
||
|
||
**Positive:**
|
||
|
||
- One session shape across browser and Rust in-line transports; hub
|
||
code is transport-blind.
|
||
- Browsers get data channels for free, riding alkcall's channels
|
||
machinery rather than a bespoke browser protocol.
|
||
- The `from_wss` consumer adapter ([ADR-070](070-from-wss-consumer-adapter.md))
|
||
becomes the mirror image of the server path: the same WS byte-stream
|
||
adaptation, then alkcall's `ChannelClient`.
|
||
- The WS framing delta from alknet is contained to alkhttp's WS adapter;
|
||
the Dispatcher, registry, and channels layers are untouched (they are
|
||
alkcall's).
|
||
|
||
**Negative:**
|
||
|
||
- Browsers must speak the channels framing (8-byte header, channel 0
|
||
open ops) rather than bare JSON envelopes. The JS client for this
|
||
lives outside alkhttp; the framing is small and the alkcall BAST
|
||
document (`chunk-header.bast.json`) is the cross-language contract.
|
||
- The WS↔byte-stream adapter is new, unproven code with backpressure
|
||
and partial-write hazards (OQ-01).
|
||
- Existing alknet-era WS clients (bare envelope per message) break. No
|
||
such client ships outside the mono-repo; the break is intentional and
|
||
one-way.
|
||
|
||
## References
|
||
|
||
- [websocket.md](../websocket.md) — the full WS session spec (its
|
||
§"Data channels for browsers" carries the same v1-cut note)
|
||
- [OQ-05](../open-questions.md) — the data-channel v1 cut deferral
|
||
(resolved 2026-09-04: the wiring landed, review 006 Units 2–3)
|
||
- [ADR-048](048-websocket-native-session-not-gateway.md) — the native
|
||
session (not gateway shape) decision this ADR amends (channel 0
|
||
framing; upgrade path)
|
||
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) — WS as
|
||
the browser bidirectional path (stands); deferral mechanics
|
||
superseded by [ADR-069](069-webtransport-out-of-scope.md)
|
||
- [ADR-070](070-from-wss-consumer-adapter.md) — the consumer-side
|
||
mirror of this decision
|
||
- alkcall ADR-014 (EventEnvelope framing), ADR-015 (stream model),
|
||
ADR-034/035 (channels wire format, pure multiplexing), ADR-036
|
||
(channel 0 pre-negotiated `alk/call`), ADR-039 (ChannelsAdapter),
|
||
ADR-043 (ChannelClient), ADR-047 (openable ALPNs are operations)
|
||
- alkcall `docs/architecture/channels-wire.md` — the 8-byte chunk
|
||
format and wire invariants |