Full-surface integration suite (tests/full_surface.rs, mcp feature): - one HttpAdapter over real TCP (ProtocolHandler::handle path) serving gateway endpoints, /openapi.json, /mcp, and the WS channels session - gateway: search/schema/call/subscribe/batch/publish presence, envelope shapes, error fidelity end-to-end - from_openapi import -> Internal-by-default invisible from the wire -> External facade composes it via env.invoke -> upstream HTTP API called end-to-end (ADR-015 composition model exercised) - to_openapi 6-path doc validated against openapiv3 over the wire - to_mcp: MCP client connects to /mcp on the served adapter, lists the 4 gateway tools, search returns ACL-filtered ops (Sub excluded) Production fix: the WS upgrade route was reserved but never wired into HttpAdapter's router (the ws-upgrade-session tests built their own router). Now wired with ws_bearer_auth (401 without a resolvable token) around ws_upgrade_handler. Docs sync: all 28 'Port notes' sections/blockquotes stripped from ported ADRs/specs; OQ-01/OQ-02 statuses corrected to resolved in overview.md, websocket.md, and the README table (open-questions.md was already current). Publish prep: cargo publish --dry-run --allow-dirty succeeds; cargo doc --no-deps warning-free (ADR link targets fixed); feature combinations (default / test-support / mcp / wss / all) compile warning-free under clippy -D warnings. Verified: cargo test (182 lib default), --all-features (227 lib + 29 integration), clippy -D warnings x3 feature sets, fmt, doc, publish --dry-run.
21 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-08-27 |
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.
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.
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.
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)