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
27 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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): 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, 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). A browser (or any WS client — Node,
a native app, the from_wss consumer
(ADR-070) 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; the decision that the WS path carries the native session rather than the gateway shape is ADR-048.
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). 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:
- Receives the HTTP upgrade request (axum's
WebSocketUpgradeextractor). - Resolves the caller's identity from the
Authorization: Bearerheader viaidentity_provider.resolve_from_token(&AuthToken { raw: token_bytes })(AuthTokenis inalkcall::core::auth) — the same auth path as any HTTP request (http-server.md §"Auth"). The upgrade is rejected (401) if no token is present; insufficient scopes for any op the browser later calls surface asFORBIDDENat call time, not at upgrade time (the upgrade doesn't know which ops the browser will call). - Upgrades to WebSocket (axum's
WebSocketUpgrade::on_upgrade), producing a full-duplexWebSocketstream. - Adapts the WS message stream to a byte stream (see §"Framing" below — the adapter is this spec's core new piece).
- Wraps the byte stream as a
Connection(Connection::from_bidi, ALPNalk/channels). - Runs the channels accept path: the in-line demux loop
(alkcall
ChannelsAdapter) installs channel 0 via theinstall_channel_zerohook — constructing channel 0'sCallConnection(identity attached) and runningDispatcher::run_loop_single_streamon it. - 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 theOpenableAlpnsrequest-extension fallback — plus the generic channel ops, the bootstrap discovery set, andop/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'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 isoperationId. - Data-channel payloads are opaque — the handler owns its framing
(alkcall ADR-035: no
stream_typeanywhere 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'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 Messages, and writes are
whole Messages. 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
AsyncReadhalf drains the buffer. Backpressure: the reader task awaits a bounded buffer slot before admitting the next message (bound: OQ-01). - Outbound (bytes → WS): the
AsyncWritehalf 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_chunkissues header+payload as separate writes, and channel 0'swrite_frameissues prefix+body as separate writes; each can surface as separate mux payloads). Oversized chunks (a single chunk exceeding the WS-message cap, up toMAX_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::shutdownmaps 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) — 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), the inbound caps (WS-06), and the write-side caps (WS-04/05/06). This is the same deployment posture asfrom_wss's drop monitor (ADR-070): 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 setwith_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) — 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: runsAccessControl::check(identity)against the op'sAccessControl, dispatches via the registry if allowed, returnsFORBIDDEN(→call.error) before the handler runs if not. - For
call.responded/call.completed/call.aborted: correlates byidvia the pending map (keyed by request ID, not by transport — alkcall ADR-015). - For
call.published(initiator-sidePubops): 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.
Data channels for browsers
A browser opens a data channel exactly as any channels consumer:
- Calls the per-ALPN open op on channel 0
(
channels/<alpn>/suborchannels/<alpn>/pub— alkcall ADR-047). - The producer side (the hub) checks the open against the registered
openable ALPNs and
check_open(identity), allocates thechannel_id, and returns it. - Chunks with that
channel_idflow over WS messages; both sides reassemble theBiStream.
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.requestedon 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); 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) 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 §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:
- 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.
- 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.
- Not addressable from other nodes.
PeerRef::Specificresolves 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;
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).
Constraints
- The WS path is the channels session, not the gateway shape
(ADR-067,
ADR-048).
The 6 gateway endpoints are HTTP-only and do not appear on WS.
Discovery via
services/list/services/schemaas call-protocol ops; streaming as native frames, not SSE. - Bearer-only auth on the upgrade request.
Authorization: Bearer→resolve_from_token. The resolved identity drivesAccessControl::checkon calls andcheck_openon 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 status amendment).
- The shared
Dispatcherruns 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). Overridable viaextra_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 | 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 | 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 | Stands; WebTransport removed from alkhttp scope (ADR-069) |
| 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 §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 | WS upgrade request credential source (same as HTTP; mechanism: alkcall ADR-003) |
| Browsers require X.509 (TLS) | ADR-027 | 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 | The WS upgrade route is on HttpAdapter's default surface |
| Custom routes collision rule | ADR-046 | The WS upgrade route must not collide with reserved default-surface paths |
from_wss consumer adapter |
ADR-070 | The outbound mirror: same adapter, ChannelClient consumer half, wss feature |
Open Questions
See 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_wssclient). - 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 — the channels-over-WS decision (this document's specification target)
- ADR-048 — the native-session decision (amended by ADR-067 for framing and path)
- ADR-070 — the consumer side
- ADR-044 — WS as the browser path (stands); ADR-069 — WebTransport out of scope
- http-server.md — the
HttpAdapterhosting 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.mdfor the chunk format and wire invariants (REQ-CH-01/02/04/05) - The alknet mono-repo's
@alkdev/pubsubWS client/server — prior art for the browser WS path lineage (the envelope shape the call protocol refined)