Files
alkhttp/docs/architecture/decisions/067-websocket-carries-channels.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

211 lines
10 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-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 23)
## 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 23)
- [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