Files
alkhttp/docs/architecture/websocket.md
T
glm-5.3-flash a9429dfb1d chore(deps): consume alkcall 0.6.0 — OpenHandler gains the plan parameter
Mechanical pass for the alkcall 0.6.0 bump (review 007's
establishment follow-ups sweep: R-01 Establishment plan payload,
R-02 OpenHandler lifetime doc note, R-03 pump_bidi extraction):

- bump alkcall 0.5 -> 0.6 (gateway feature unchanged)
- the OpenHandler signature gained a plan parameter —
  Fn(Value, Option<ChannelPlan>, Connection, AuthContext) ->
  JoinHandle<()> — so the test's echo_open_handler closure gains
  `_plan` (the only closure-construction site in this crate)
- no ferry change: this crate constructs no Establishment and passes
  the Option<OpenEstablisher> through unchanged (OpenableAlpn fields
  and register_openable_with_establisher threading are unchanged in
  0.6.0); the establisher's Establishment.plan now reaches the pump
  handler's second parameter process-locally (ADR-049 amendment 2)
- docs updated: websocket.md (plan threading + pump_bidi pointer),
  OpenableAlpn field docs, with_ws_openable_alpns doc comment
- CHANGELOG [Unreleased] consumption-wave entry

Verification: cargo test (454 passed), cargo test --all-features
(587 passed), clippy -D warnings (default + all-features),
fmt --check, cargo doc --no-deps clean
2026-09-07 09:24:00 +00:00

494 lines
27 KiB
Markdown

---
status: draft
last_updated: 2026-09-04
---
# WebSocket — the Browser Bidirectional Path (Channels over WS)
WebSocket is the browser bidirectional path to the call protocol and
the channels protocol. A WS session carries the **channels protocol**
([ADR-067](decisions/067-websocket-carries-channels.md)): the 8-byte
chunk header multiplexes N logical channels over the WS binary message
stream; **channel 0 is pre-negotiated as `alk/call`** and carries the
native call-protocol session, dispatched by the shared `Dispatcher`.
This supersedes the alknet design where WS carried one bare
`EventEnvelope` per message.
A WS session is a **native channels session, not the HTTP gateway
shape** — the gateway endpoints (`/search`, `/schema`, `/call`,
`/batch`, `/subscribe`, `/publish`) are the HTTP one-directional
projection and **do not appear on the WebSocket path**
([ADR-048](decisions/048-websocket-native-session-not-gateway.md), as
amended by ADR-067). Discovery is via `services/list`/`services/schema`
as call-protocol operations on channel 0.
## What
The WebSocket path is an axum WS upgrade handler on the same
`HttpAdapter` that serves `h2`/`http/1.1`
([http-server.md](http-server.md)). A browser (or any WS client — Node,
a native app, the `from_wss` consumer
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) in reverse)
opens an HTTP/1.1 or HTTP/2 request to the upgrade path, authenticates
by bearer token on the upgrade request, and the resulting full-duplex
WS connection is a channels connection: the 8-byte chunk format
(alkcall ADR-034) runs over it exactly as over TCP+TLS.
## Why
WebSocket is the HTTP-family transport that restores the alk protocols'
native bidirectionality for browsers — HTTP/1.1 + HTTP/2 are
request/response (a one-directional projection of the call protocol),
while WS is a full-duplex, long-lived, framed-message channel. Carrying
the **channels protocol** (rather than a bare envelope stream, the
alknet choice) makes a browser session structurally identical to a Rust
in-line channels session (TCP+TLS): one session shape, one dispatch
path, data channels included. The decision is
[ADR-067](decisions/067-websocket-carries-channels.md); the decision
that the WS path carries the native session rather than the gateway
shape is [ADR-048](decisions/048-websocket-native-session-not-gateway.md).
### The consumer-side mirror: `from_wss`
The same session serves the outbound direction: a Rust process dials a
remote node's WSS endpoint and consumes its operations
([ADR-070](decisions/070-from-wss-consumer-adapter.md)). Server upgrade
and consumer dial share the WS↔byte-stream adapter; the session halves
are alkcall's (`ChannelsAdapter` accept path vs `ChannelClient`
consumer path).
## Architecture
### The WS upgrade handler
The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum
route on `HttpAdapter`'s router. The handler:
1. Receives the HTTP upgrade request (axum's `WebSocketUpgrade`
extractor).
2. Resolves the caller's identity from the `Authorization: Bearer`
header via `identity_provider.resolve_from_token(&AuthToken { raw:
token_bytes })` (`AuthToken` is in `alkcall::core::auth`) — the same
auth path as any HTTP request
([http-server.md](http-server.md) §"Auth"). The upgrade is rejected
(`401`) if no token is present; insufficient scopes for any op the
browser later calls surface as `FORBIDDEN` at call time, not at
upgrade time (the upgrade doesn't know which ops the browser will
call).
3. Upgrades to WebSocket (axum's `WebSocketUpgrade::on_upgrade`),
producing a full-duplex `WebSocket` stream.
4. Adapts the WS message stream to a byte stream (see §"Framing" below
— the adapter is this spec's core new piece).
5. Wraps the byte stream as a `Connection` (`Connection::from_bidi`,
ALPN `alk/channels`).
6. Runs the channels accept path: the in-line demux loop
(alkcall `ChannelsAdapter`) installs channel 0 via the
`install_channel_zero` hook — constructing channel 0's
`CallConnection` (identity attached) and running
`Dispatcher::run_loop_single_stream` on it.
7. Data channels (1..N) route per the deployment's openable-ALPN
registrations; the browser opens them via the per-ALPN open ops on
channel 0 (alkcall ADR-047), the same mechanism any consumer uses.
Wired 2026-09-04 (review 006 Unit 2+3): the hook registers the
deployment's openables — `HttpAdapter::with_ws_openable_alpns`
(default: none) or the `OpenableAlpns` request-extension fallback —
plus the generic channel ops, the bootstrap discovery set, and
`op/register`, on the per-session fork (alkcall ADR-047 §4
amendment #2).
The **default upgrade path is `/alk/channels`** (was `/alknet/call` in
the alknet design). The path must not collide with the reserved
gateway/`/healthz`/`/openapi.json`/MCP/custom-route paths per
[ADR-046](decisions/046-assembly-layer-custom-http-routes.md)'s
collision rule; `/alk/channels` namespaces away from the reserved set
naturally. The upgrade runs over HTTP/1.1 (RFC 6455) or HTTP/2
(extended CONNECT, RFC 8441); axum/hyper supports both, and the handler
does not branch on which — the WS frame stream is the same once the
upgrade completes.
### Framing: WS messages carry chunks; channel 0 carries length-prefixed envelopes
The wire layering:
```
WS binary message (message boundary = transport frame)
└── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes)
└── payload
channel 0: [len: u32 BE][EventEnvelope JSON] (call frame)
channel N: handler-owned framing (opaque to channels)
```
- **WS messages are transport frames, not protocol boundaries.** The
WS binary message stream is treated as a byte stream; the 8-byte
chunk header is the only framing. A chunk may span WS messages and a
WS message may carry chunk fragments — the adapter (below) is the
seam. (In practice the write path usually emits one chunk per
message, but nothing may depend on it: channel 0's frame writer
issues two writes — length prefix, then body — which the mux can
deliver as two mpsc payloads.)
- **Channel 0's payload is the call protocol's frame format**
(alkcall ADR-014): a 4-byte big-endian length prefix + UTF-8 JSON
`EventEnvelope`. This is exactly the framing channel 0 uses over
TCP+TLS; the WS path is not special at this layer. **Chunk ≠ frame**:
a single frame may arrive as multiple chunks (the frame writer
issues prefix and body as separate writes through the mux —
live-confirmed in the ws-byte-adapter POC), so channel-0 consumers
MUST reassemble length-prefixed frames from the channel-0 byte
stream, never parse per-chunk. The request payload's operation name
key is `operationId`.
- **Data-channel payloads are opaque** — the handler owns its framing
(alkcall ADR-035: no `stream_type` anywhere in the channels layer).
- **Text WS messages are rejected** with a protocol-level close (code
`1002`). All frames are binary.
- The alknet design's "one envelope = one WS message, no length
prefix" rule is superseded; see
[ADR-048](decisions/048-websocket-native-session-not-gateway.md)'s
status amendment.
#### The WS ↔ byte-stream adapter
The channels machinery reads and writes **bytes** (`AsyncRead` +
`AsyncWrite`): the demux does `read_exact` on the 8-byte header and
payload; the mux writes chunk bytes. A WebSocket is
**message-oriented**: axum yields whole `Message`s, and writes are
whole `Message`s. The adapter bridges the two, in both directions:
- **Inbound (WS → bytes):** a background task pops WS binary messages
and appends their bytes into a shared buffer; the `AsyncRead` half
drains the buffer. Backpressure: the reader task awaits a bounded
buffer slot before admitting the next message (bound: OQ-01).
- **Outbound (bytes → WS):** the `AsyncWrite` half accumulates bytes
into a pending buffer; a background task scans the pending bytes for
complete chunks (8-byte header → payload length) and emits each
complete chunk as one WS binary message, carrying any partial tail
until its chunk completes. The adapter **parses the outgoing byte
stream** to find chunk boundaries — it does not assume one write
equals one chunk (verified against alkcall: `write_chunk` issues
header+payload as separate writes, and channel 0's `write_frame`
issues prefix+body as separate writes; each can surface as separate
mux payloads). Oversized chunks (a single chunk exceeding the
WS-message cap, up to `MAX_CHUNK_LEN` = 16 MiB) are split across
multiple WS messages — legal, since the receiver's boundary is the
chunk header, not the message. Flush semantics are an OQ-01 item.
- **Close mapping:** WS close (either side) → transport EOF → the
demux clears all channels (REQ-CH-02: every handler sees EOF) and
channel 0's dispatch loop fails outstanding pendings with
`connection closed`. `AsyncWrite::shutdown` maps to the zero-length
EOF sentinel (REQ-CH-01) followed by a WS Close frame.
The adapter is shared with the `from_wss` consumer path
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one
implementation, both directions.
#### Idle-read timeout: progress semantics + the no-keepalive decision (WS-01, WS-13)
The WS pumps carry an **idle-read eviction knob**
(`HttpAdapter::with_ws_idle_timeout`, default
`DEFAULT_WS_IDLE_TIMEOUT` = 60 s, disable with `None`). Its semantics,
decided in review-002 (WS-13), are **progress-based, deliberately
strict**:
- The deadline resets on **demux progress** — bytes actually forwarded
into the byte stream that *complete* an inbound chunk (a full
8-byte header + its declared payload). WS message arrival resets
nothing.
- Therefore the dribble stall (declare a chunk, deliver its payload a
byte per message) hits the deadline and is evicted with a `1001`
(GoingAway) close — even though messages keep arriving — while a
peer delivering complete chunks, however slowly per message, re-arms
the window with each chunk and survives.
- **There is no WS ping/pong keepalive, on purpose.** A keepalive
rescues app-silence only by re-arming the deadline — and a pong is
indistinguishable from the dribble's almost-invisible arrivals, so
adding one would reopen the stall it exists to seal. The recorded
decision (module doc of `src/websocket/byte_adapter.rs`, option (b)
of the two WS-13 alternatives): **60 s of no chunk progress is an
intentional eviction line, even for a silent subscription.**
- A deployment running long-lived silent-but-alive sessions (quiet
subscriptions that outlast the window) disables the knob with
`with_ws_idle_timeout(None)` and leans on the remaining levers:
the session registry's forced-eviction
([`WsSessions::abort`](#connection-local-overlay)), the
inbound caps (WS-06), and the write-side caps (WS-04/05/06). This is
the same deployment posture as `from_wss`'s drop monitor
([ADR-070](decisions/070-from-wss-consumer-adapter.md)): the idle
knob bounds *demux parking*, not app liveness.
- **Data channels sharpen this knob's bite** (surfaced with the
data-channel wiring, review 006): a silent-but-alive *data channel*
(an idle SSH-style session, an open-but-quiet tunnel) forwards no
chunks, so the deadline runs out and the whole WS session is evicted
with `1001` — exactly the same semantics as before the wiring
(this is not a new rule), but the 60 s default now hits more often
because sessions live longer and carry quiet channels. A deployment
serving long-lived interactive channels should set
`with_ws_idle_timeout(None)` at assembly and lean on the same levers
as above; the idle knob remains a demux-parking bound, not an
application keepalive.
- Layered note (the WS-13/FWD-15 interaction recorded here): the
*HTTP* SSE path (`/subscribe`) sends server-side keep-alive comment
frames every 15 s — see
[http-server.md](http-server.md)) — because its idle enemy is
LB/proxy timeouts, and its keep-alive does not reset any
progress deadline, it cannot reopen the WS-13 hole. The two live at
different layers: SSE keep-alive fights transport fires; the WS idle
knob bounds demux parking. Both documented in the same pass per
review-002 Unit-2 sequencing.
### Dispatch: channel 0 = the shared `Dispatcher`, unchanged
Channel 0's session is the alknet design's native session, verbatim:
- For `call.requested`: runs `AccessControl::check(identity)` against
the op's `AccessControl`, dispatches via the registry if allowed,
returns `FORBIDDEN` (→ `call.error`) before the handler runs if not.
- For `call.responded`/`call.completed`/`call.aborted`: correlates by
`id` via the pending map (keyed by request ID, not by transport —
alkcall ADR-015).
- For `call.published` (initiator-side `Pub` ops): routed to the
matching sink (alkcall ADR-046).
- Writes response frames back through channel 0's shared writer.
Peer authorization flows through `AccessControl::check` against the
resolved identity — an op with `AccessControl::default()` is callable
by any authenticated browser; an op with `required_scopes` only by
identities whose scopes satisfy them; an op with
`Visibility::Internal` is never callable from the wire (`NOT_FOUND`
before ACL). This is alkcall ADR-017's model; see also
[ADR-015](decisions/015-privilege-model-and-authority-context.md).
### Data channels for browsers
A browser opens a data channel exactly as any channels consumer:
1. Calls the per-ALPN open op on channel 0
(`channels/<alpn>/sub` or `channels/<alpn>/pub` — alkcall ADR-047).
2. The producer side (the hub) checks the open against the registered
openable ALPNs and `check_open(identity)`, allocates the
`channel_id`, and returns it.
3. Chunks with that `channel_id` flow over WS messages; both sides
reassemble the `BiStream`.
The deployment decides which ALPNs are openable — the browser reaches
exactly what a Rust consumer on an in-line connection would reach.
This is what makes the WASM-SSH-client-in-a-browser use case (the one
that motivated the alknet WebTransport track) workable over WS: the
SSH byte stream rides a data channel.
The openable set is declared with `HttpAdapter::with_ws_openable_alpns`
(each `OpenableAlpn { spec, open_handler, establisher,
establisher_timeout }`; the ALPN-specific handlers and establishers
stay in the ALPN crates — alkhttp ferries the registrations). The
establisher (alkcall 0.5.0 / ADR-049) is the awaited establishment
phase of the open op: `None` (the default) keeps the pre-0.5 shape —
the open replies as soon as the pump handler spawns; `Some`
semantically-validating hook dials/prepares the backend before the
reply, and a bounded failure resolves `channel:open_failed` with
`details.reason` (the channel never exists consumer-side). The
per-registration timeout override bounds the establisher when the
dispatch carries no deadline. The establisher's `Establishment.plan`
payload (alkcall 0.6.0 / ADR-049 amendment 2 — a typed-opaque
`ChannelPlan`) is threaded process-locally to the pump handler's
second parameter: the establisher and the handler agree on the
concrete type, so ALPN crates dial/allocate in the establisher and
deliver the live handle to their handler without a side-channel
handoff. Data-plane handlers that pump a channel stream against a
peer's split halves can use alkcall's `channels::pump_bidi` helper
(alkcall 0.6.0 / ADR-050). The `OpenableAlpns` request-extension
fallback is available for bare-registry/custom
upgrade routes. Cap policy is the `ChannelsPolicy` extension (one
instance consulted by both the open wrappers and the demux teardown).
Peer-announced ops (`op/register`) land in the connection-local
overlay; discovery: `services/list` (the session's own surface) and
`services/list-peers` (peer-announced ops, alkcall 0.3.1). E2E gates
for the whole flow live in `tests/ws_upgrade_session.rs`.
WS-session discovery is the **bootstrap set**: the hook registers
alkcall's `services/list` / `services/schema` / `services/list-peers`
closed over the session's fork (so the listing sees the session's own
openables — the F-06 shape), which **shadows by design** any
`services/*` operation the deployment registered on its base registry
(`register` inserts by name, so the per-session fork's bootstrap
registrations overwrite the deployment's on the WS path). A deployment
with a custom `services/list` keeps it on every other transport and
gets alkcall's bootstrap listing on WS sessions — recorded here so the
asymmetry is a decision, not a surprise (review 007 WS-31).
### Bidirectionality
The WS channels session inherits both protocols' native
bidirectionality (alkcall ADR-015 for calls; the channels protocol is
symmetric by construction):
- **Calls:** both sides can send `call.requested` on channel 0. The
browser calls hub ops; the hub can call browser-registered ops over
the same session, same pending map, same framing.
- **Channels:** the browser can open data channels (via open ops); the
hub can open them toward the browser if the browser side registers
openable ALPNs (a browser that is also a producer).
The common case — a browser that registers no ops and opens no
channels — is a use-case scoping, not an architectural limitation.
### Connection-local overlay
A browser over WS has no `PeerId` on the hub's side. Any ops the
browser registers land in a **connection-local Layer 2 overlay**
(alkcall ADR-019; the mechanism the hub reaches via the live
connection handle's `overlay_env()`). When the WS connection closes
(browser closes the tab, network drops), the overlay and all its
registered ops drop — no explicit deregistration. The hub reaches
browser ops through the connection handle, not through
`PeerRef::Specific` (the browser is not a peer — see below).
### Streaming: native `call.responded` events, no SSE
A `Sub` operation invoked on channel 0 streams `call.responded`
frames as channel-0 chunks — **no SSE framing**. SSE is the h2/http1.1
streaming projection (the gateway's `/subscribe` endpoint per
[ADR-042](decisions/042-openapi-gateway-pattern.md)); on WS it is
unnecessary because WS is already a framed full-duplex channel. The
browser receives `call.responded` events one per frame, with the same
`id` correlating them to the original `call.requested`;
`call.completed` closes the subscription; `call.aborted` closes it
with an error frame. This is identical to how subscriptions work over
any in-line channels transport.
A `Pub` operation (browser as initiator) publishes `call.published`
frames from the browser into channel 0 — the HTTP gateway's `/publish`
([ADR-068](decisions/068-gateway-publish-endpoint.md)) has no WS
equivalent because the native mechanism already exists.
On WS client disconnect (the browser closes the tab mid-subscription),
the session teardown detects the close and aborts in-flight
subscriptions — the abort cascade runs per alkcall ADR-020.
### Browsers are not alk peers
A browser over WS authenticates by bearer token, gets no `PeerId`,
does not enter `PeerCompositeEnv`, and its registered ops (if any) land
in the connection-local overlay. The rationale
([ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4,
amended by alknet ADR-044 §5) is a load-bearing distinction:
**"Peer" means an addressable node in the call-protocol peer graph** —
a stable `PeerId`, reachable via `PeerRef::Specific`, whose identity is
stable across reconnects. It does *not* mean "any endpoint that
exchanges calls during a live session." A browser is the second thing
but not the first:
1. **No stable cryptographic identity of its own.** A peer entry is
anchored to fingerprints the *peer* presents and the local node
pins. A browser presents a bearer token the *hub* issued — hub
bookkeeping, not pinnable identity. There is nothing to put in
fingerprints.
2. **Ephemeral.** Close the tab → connection dies → the
connection-local overlay dies with it. A peer entry keyed to a
browser would be permanently dead within seconds.
3. **Not addressable from other nodes.** `PeerRef::Specific` resolves
through a peer entry; another node has no way to reach "the browser
currently connected to hub-A." The hub holds that connection as a
live handle, not a peer-graph entry.
The browser is a **bidirectional call target during a live session**,
not a **peer-graph member**; the connection-local overlay is what
gives the former without the latter.
### Auth: bearer token on the upgrade request
Inbound WS auth is `Authorization: Bearer <token>` on the HTTP upgrade
request, resolved via `IdentityProvider::resolve_from_token()` — the
same path as any HTTP request ([ADR-004](decisions/004-auth-as-shared-core.md);
mechanism owned by alkcall ADR-003). Bearer-only is the auth mechanism;
other schemes would be added as axum middleware on the upgrade route
(two-way door). The resolved identity drives `AccessControl::check` on
every `call.requested` the browser sends and `check_open` on every
channel-open op — per-privilege filtering is free via
`services/list`'s existing `AccessControl` filtering.
For the consumer side (`from_wss`), the same shape applies outbound:
the adapter presents the token on its dial, sourced from
`Capabilities` (the no-env-vars path,
[ADR-014](decisions/014-secret-material-flow-and-capability-injection.md)).
## Constraints
- **The WS path is the channels session, not the gateway shape
([ADR-067](decisions/067-websocket-carries-channels.md),
[ADR-048](decisions/048-websocket-native-session-not-gateway.md)).**
The 6 gateway endpoints are HTTP-only and do not appear on WS.
Discovery via `services/list`/`services/schema` as call-protocol ops;
streaming as native frames, not SSE.
- **Bearer-only auth on the upgrade request.** `Authorization: Bearer`
`resolve_from_token`. The resolved identity drives
`AccessControl::check` on calls and `check_open` on channel opens.
- **Browsers are not alk peers.** Bearer token, no `PeerId`,
connection-local Layer 2 overlay for browser-registered ops.
- **One WS binary message = one chunk; channel 0 frames are
length-prefixed JSON inside the chunk payload.** Text messages are
rejected. The alknet "one envelope per message" rule is superseded
([ADR-048](decisions/048-websocket-native-session-not-gateway.md)
status amendment).
- **The shared `Dispatcher` runs on channel 0 unchanged
(alkcall ADR-015).** The dispatch half is one implementation across
all in-line transports; only connection establishment differs.
- **The default upgrade path is `/alk/channels`; it must not collide
with reserved paths ([ADR-046](decisions/046-assembly-layer-custom-http-routes.md)).**
Overridable via `extra_routes`.
- **The WS↔byte-stream adapter is the single seam between axum's WS
and alkcall's byte-oriented channels machinery** — shared with
`from_wss`, semantics tracked in OQ-01.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| WS carries the channels protocol (channel 0 = `alk/call`) | [ADR-067](decisions/067-websocket-carries-channels.md) | WS = in-line channels substrate; 8-byte chunk demux; upgrade path `/alk/channels`; supersedes bare-envelope framing |
| WS carries the native session, not the gateway shape | [ADR-048](decisions/048-websocket-native-session-not-gateway.md) | Channel 0 carries the native call session; gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` |
| Call protocol stream model | alkcall ADR-015 | Stream-agnostic correlation; the pending map and dispatch loop run over any transport |
| WS as the browser bidirectional path | [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) | Stands; WebTransport removed from alkhttp scope ([ADR-069](decisions/069-webtransport-out-of-scope.md)) |
| Operation registry layering (connection-local overlay) | alkcall ADR-019 | Browser-registered ops land in a per-connection overlay that dies with the WS connection |
| Browsers are not alk peers | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 | Bearer token, no `PeerId`; addressability vs bidirectionality rationale |
| Abort cascade on disconnect | alkcall ADR-020 | WS close mid-subscription aborts in-flight ops, cascading to descendants |
| Bearer auth via `resolve_from_token` | [ADR-004](decisions/004-auth-as-shared-core.md) | WS upgrade request credential source (same as HTTP; mechanism: alkcall ADR-003) |
| Browsers require X.509 (TLS) | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | The WS upgrade runs over the same TLS as h2/http1.1; TLS provisioning is an alknet concern |
| Stealth: HTTP handler on standard ALPNs serves WS upgrade | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | The WS upgrade route is on `HttpAdapter`'s default surface |
| Custom routes collision rule | [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) | The WS upgrade route must not collide with reserved default-surface paths |
| `from_wss` consumer adapter | [ADR-070](decisions/070-from-wss-consumer-adapter.md) | The outbound mirror: same adapter, `ChannelClient` consumer half, `wss` feature |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-01** (resolved): WS ↔ byte-stream adaptation — resolved by the
production adapter (`byte_adapter.rs`): inbound bounded mpsc (64
slots, backpressure), outbound chunk parser with 1 MiB message cap,
flush no-op, close → EOF mapping; validated end-to-end in both
directions (server upgrade path + `from_wss` client).
- **OQ-04** (open): browser client library ownership — the JS/TS
client speaking channels-over-WS (chunk framing, channel 0 open ops,
envelope handling) is needed for browser consumers; it lives outside
this crate, but the BAST chunk-header contract
(alkcall `chunk-header.bast.json`) is the shared reference.
## References
- [ADR-067](decisions/067-websocket-carries-channels.md) — the
channels-over-WS decision (this document's specification target)
- [ADR-048](decisions/048-websocket-native-session-not-gateway.md) —
the native-session decision (amended by ADR-067 for framing and path)
- [ADR-070](decisions/070-from-wss-consumer-adapter.md) — the consumer
side
- [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md)
— WS as the browser path (stands); [ADR-069](decisions/069-webtransport-out-of-scope.md)
— WebTransport out of scope
- [http-server.md](http-server.md) — the `HttpAdapter` hosting the
upgrade route
- The alkcall crate's `docs/architecture/` — call protocol wire format
(ADR-014), stream model (ADR-015), registry layering (ADR-019), abort
cascade (ADR-020), channels wire format (ADR-034/035), channel 0
pre-negotiation (ADR-036), ChannelsAdapter (ADR-039), ChannelClient
(ADR-043), openable ALPNs (ADR-047); `channels-wire.md` for the
chunk format and wire invariants (REQ-CH-01/02/04/05)
- The alknet mono-repo's `@alkdev/pubsub` WS client/server — prior art
for the browser WS path lineage (the envelope shape the call
protocol refined)