--- status: draft last_updated: 2026-07-18 --- # alknet-channels — Overview ## What `alknet-channels` is a multiplexing proxy crate. It implements `ProtocolHandler` for the `alknet/channels` ALPN: it receives one bidirectional transport stream, reads 8-byte chunk headers, and routes each chunk's payload to the right logical channel. Each channel is reassembled into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per ADR-009) and presented to its handler as a `Connection` — the handler doesn't know it's inside a channels connection. Channel 0 is pre-negotiated as `alknet/call` (ADR-036). Every other channel is opened dynamically via `channel/open` on channel 0 (ADR-037) and routed through the same `HandlerRegistry` as top-level connections. The channels layer does no protocol work itself — it is a re-framing proxy that converts between "one transport stream carrying N channels" (the wire) and "N independent `BiStream` handles" (what handlers see). The channels layer has no `stream_type` concept (ADR-035) — the handler owns its sub-stream multiplexing on the `BiStream` it receives. ## Why ### The problem: three multiplexing models that don't compose Before channels, alknet had three multiplexing models: | Model | Where | Mechanism | |-------|-------|-----------| | Connection-level | ALPN router | One ALPN per QUIC connection | | Stream-level | QUIC native | Many bidi streams per connection | | Sub-stream-level | TTY chunk format | 4 logical channels within one bidi stream | A docker client needing both JSON call operations and raw TTY sessions required **two separate QUIC connections** with different ALPNs. The call protocol can't say "for this operation, open a TTY stream." The hub, bridging browsers and spokes over multiple transports, faced an O(protocols × transports × spokes) matrix of per-protocol framing parsers and per-ALPN connection management. ### The collapse: one multiplexing model, one connection per leg With `alknet/channels`, one connection carries everything: ``` Browser ──WebTransport──► Hub ──QUIC──► Spoke alknet/channels alknet/channels ┌─────────────┐ ┌─────────────┐ │ ch0: call │ │ ch0: call │ │ ch1: tty │ relay │ ch1: tty │ │ ch2: ssh │ ◄─────► │ ch2: ssh │ │ ch3: tunnel │ │ ch3: tunnel │ └─────────────┘ └─────────────┘ ``` The hub's relay is channel-by-channel byte forwarding (with `channel_id` rewrite — ADR-042), not per-protocol framing parsers. The hub's complexity collapses from O(protocols × transports × spokes) to O(channels). The collapse is at three levels: 1. **One connection per leg, not one per protocol.** All needs (call, TTY, SSH, tunnel) ride as channels on one connection per leg. 2. **One multiplexing model, not three.** Connection-level, stream-level, and sub-stream-level all become channels chunks. 3. **The call protocol orchestrates from inside.** Channel 0 is `alknet/call` on both legs. The call protocol's `OperationRegistry`, `AccessControl`, and `forwarded_for` machinery govern channel lifecycle with no new auth. ### The separation: channels layer is pure channel multiplexing The channels layer's job is "one connection carries N channels, routed by `channel_id`." It does not know about TTY's sub-streams, SSH's channel protocol, or how call frames its JSON. Handlers own their sub-multiplexing on the `BiStream` the channels layer gives them (ADR-035). - **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream` per channel (per ADR-009). The handler sub-multiplexes it however it wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw bytes, SSH's channel protocol. - **The channels layer has no `stream_type` concept.** Not in its 8-byte header, not in its code, not in its mental model. `stream_type` is the inner layer's framing byte, carried transparently in the payload. - **The control channel is handler-internal.** TTY sub-demuxes control from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer doesn't carry control. - **Recursive composition is literal.** A channel with ALPN `alknet/channels` runs another channels demux on its `BiStream`. The outer layer strips its 8-byte header; the inner layer parses its own 8-byte header from the payload. ## Architecture The crate has two internal components (ADR-039): - **`ChannelsAdapter`** — implements `ProtocolHandler` for `alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte chunk headers, and routes chunks to the `ChannelManager`. The read/demux half. - **`ChannelManager`** — the shared state. Holds `channel_id → ChannelState`, the `HandlerRegistry` reference, and the `OperationRegistry` reference. The reassemble/allocate half. What the `channel/open` operation handler closes over. Each channel is presented to its handler as a `Connection` constructed via `Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as amended by ADR-035). The handler calls `accept_bi()` once (yield-once per channel) and gets a `BiStream` — identical to how it works on a top-level QUIC connection. See [channels-adapter.md](channels-adapter.md) for the full adapter/manager design. ## Crate dependencies ``` alknet-channels-core ├── alknet-core (ProtocolHandler, Connection, HandlerRegistry, │ BidiStreamSource, BiStream, AuthContext) ├── tokio (spawn, mpsc, io) ├── bytes (Bytes for chunk payloads) ├── async-trait ├── thiserror └── tracing alknet-channels-call ├── alknet-channels-core (ChannelManager, ChannelsAdapter, │ ChannelBidiStreamSource, ChannelClient) ├── alknet-call (OperationRegistry, HandlerKind, make_handler, │ make_streaming_handler, CallError, ResponseEnvelope) └── tokio alknet-hub (the existing hub crate — consumes channels) ├── alknet-channels-call └── alknet-call (from_call, CallAdapter, forwarded_for — ADR-042) worker crates (any crate that dials a hub — consumes channels) └── alknet-channels-call (ChannelClient — ADR-043) ``` `alknet-channels-core` is the pure multiplexer — wire format, demux/mux, `ChannelBidiStreamSource`, `ChannelManager`. It depends on `alknet-core` only. No `alknet-call` dependency. ALPN-blind, call-protocol-blind, transport-blind. This is where the "streams are streams" insight lives. `alknet-channels-call` is the call-protocol coupling — channel 0 pre-negotiation as `alknet/call` (ADR-036), the four lifecycle operations (ADR-037) registered on the call protocol's `OperationRegistry`, and `ChannelClient` (ADR-043). This is where the call-protocol coupling lives, isolated from the pure multiplexer. The hub and worker are **consumers**, not sub-crates. The existing `alknet-hub` crate IS the channels hub — it depends on `channels-call` and uses channels as its substrate, with the relay logic (ADR-042) living in `alknet-hub` alongside its existing peer lifecycle and service discovery responsibilities. A worker is any crate that uses `ChannelClient` to dial. There are no `channels-hub` or `channels-worker` sub-crates. See ADR-044 for the full decomposition rationale. ## ALPN `alknet/channels` — the ALPN the `ChannelsAdapter` registers on. One ALPN per channels connection; the connection carries N logical channels, each with its own ALPN (negotiated via `channel/open`). ## Transport agnosticism The channels wire format works over any ordered, reliable bidirectional byte stream: | Transport | How | |-----------|-----| | QUIC bidi stream | `alknet/channels` ALPN on a QUIC connection; one bidi stream carries all channels | | TCP+TLS | `alknet/channels` ALPN on a TLS connection; the TCP stream carries all channels | | WebTransport | `alknet/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alknet/channels`) | | SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) | | Another channels connection | recursive composition (channel type `alknet/channels` inside `alknet/channels`) | The same wire format, the same chunk reassembly, the same `Connection` abstraction. The transport is a parameter, not a design constraint. `Connection::from_bidi` / `from_source` (ADR-007/070/092) handles the transport-agnostic `Connection` construction. ## WASM compatibility The wire format's core is pure byte manipulation — `parse_header` / `write_header` are pure functions with no platform dependencies. The de-risk POC validated the sync core compiles under `wasm32-unknown-unknown`. The async shell (demux/mux) wraps this core with `read_exact`/`write_all` and `mpsc` routing. The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR- 075) — pure byte routing with no platform or protocol dependencies. A WASM build can read chunks from a WebTransport `BiStream`, reassemble them, and present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The handlers themselves may or may not be WASM-compatible (russh's client is; `portable_pty` is not), but the channels layer is WASM-compatible by construction. The async shell and `alknet-core` dep graph are not fully WASM-clean yet (transitive `getrandom`/`rand` deps) — this is an implementation concern, not an architecture concern. The sync core's WASM compatibility is validated. ## Relationship to existing crates ### alknet-call Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It runs on channel 0 exactly as on a top-level `alknet/call` connection. The `CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly and dispatches operations — it doesn't know it's inside channels. The call protocol's `EventEnvelope` framing (ADR-014) is the channels payload; the channels layer carries it transparently. What changes: the call protocol gains a new class of operations — channel lifecycle (ADR-037). These are registered on the `OperationRegistry` at assembly time and dispatched through the existing `OperationContext` / `AccessControl::check` path. ### alknet-tty The TTY crate gains a `channels` feature that enables inside-channels mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY adapter uses its own 5-byte wire format (ADR-052). The two modes differ only in *where the `BiStream` comes from* — a top-level connection vs a channels-backed `Connection`. The same `wire.rs` code runs in both modes (ADR-077): the channels layer strips its 8-byte header and hands TTY the payload bytes; TTY parses its 5-byte header from the payload. The `TtyBackend` trait and `TtyHandle` are unchanged; backends don't know which mode the adapter is in. ### alknet-ssh (future) SSH as a channel type: an `alknet/ssh` channel carries the SSH binary protocol on its `BiStream`. The channels layer hands the reassembled `BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels transport: an SSH `direct-tcpip` channel could carry a channels connection (channels-over-SSH). The SSH crate doesn't need to know about channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a `Connection`. SSH multiplexes internally (its own channel protocol rides the channels payload transparently). ### alknet-docker Docker lifecycle operations are call operations on channel 0 (unchanged from ADR-058). Interactive exec/attach opens a TTY channel via `channel/open` with ALPN `alknet/tty` and backend `docker`. No separate `alknet/tty` connection needed — one `alknet/channels` connection handles both JSON operations and raw TTY sessions. ### alknet-hub The hub is the primary consumer. With channels, the hub holds one channels connection per leg (browser↔hub, hub↔spoke) and relays channels between them. The hub translates `channel/open` on channel 0 (re-issues on the spoke leg with `forwarded_for` — ADR-042) and byte-forwards data channels with `channel_id` rewrite. The hub's complexity collapses from O(protocols × transports × spokes) to O(channels). ## Design Decisions All design decisions are documented as ADRs in [decisions/](decisions/). | ADR | Decision | Summary | |-----|----------|---------| | [071](decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door | | [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte | | [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` | | [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned | | [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-035 — `into_sub_streams` removed) | | [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 | | [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs | | [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload | | [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level | | [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite | | [080](decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) | | [081](decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers | ## Open Questions Open questions are tracked in [open-questions.md](open-questions.md). Key questions affecting this crate: - **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam** — extracted as `alknet-client` with three dial methods. `ChannelClient`'s API is transport-agnostic (`from_connection`); the dial is the shared seam, now extracted. See [ADR-045](decisions/089-alknetclient-native-dial-seam.md). - **OQ-56** (deferred(scope)): Full channel-level flow-control windowing — bounded-buffer is decided (ADR-040); full windowing is an extension blocked on a real HOL-blocking deployment observation. - **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core — the *contract* is decided (ADR-078); the *helper* is blocked on a second two-pump handler existing. - **OQ-68** (open): Add/strip API shape — whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* (channels strips, handler parses payload) is decided (ADR-035); the *function surface* is not.