Prune the channels spec to reflect the stream-unification resolution (docs/research/stream-unification/findings.md): the channels wire format goes from 9 bytes to 8 bytes, the channels layer no longer carries a stream_type concept, into_sub_streams() is removed, and TTY always uses its 5-byte format (carried transparently in the channels payload). ADR-093 is the umbrella decision (the channels-layer consequence of ADR-092's BiStream handler leaf): every channel is a BiStream, the handler owns its sub-stream multiplexing, the channels layer routes by channel_id only. Amends ADR-071 (8-byte header, no stream_type), ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses ADR-077 (TTY always 5-byte), and the channels-facing clauses of ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note (into_sub_streams preservation subsequently reversed by ADR-093) and the missing ADR-092 cross-reference on ADR-070. Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is decided in ADR-093, the function surface is open; two-way door, low priority, decision-ready when the channels crate's implementation begins). Rewrites the 7 channels spec docs (README, overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client) to describe the post-amendment shape as current, with the 8-byte header, the add/strip composition, single accept_bi accessor, BiStream per channel, and TTY-always-5-byte. Touch-up cross-references in hub README, client README, ADR-085, and the OQ-45/47/65 question files (TTY-internal stream_type 3 → STREAM_CTRL_IN; channels 9-byte → 8-byte).
16 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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-092) 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-072). Every other channel
is opened dynamically via channel/open on channel 0 (ADR-073) 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-093) — 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-079), not per-protocol framing parsers. The hub's complexity
collapses from O(protocols × transports × spokes) to O(channels).
The collapse is at three levels:
- One connection per leg, not one per protocol. All needs (call, TTY, SSH, tunnel) ride as channels on one connection per leg.
- One multiplexing model, not three. Connection-level, stream-level, and sub-stream-level all become channels chunks.
- The call protocol orchestrates from inside. Channel 0 is
alknet/callon both legs. The call protocol'sOperationRegistry,AccessControl, andforwarded_formachinery 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-093).
- Every channel is a
BiStream.accept_bi()yields oneBiStreamper channel (per ADR-092). 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_typeconcept. Not in its 8-byte header, not in its code, not in its mental model.stream_typeis the inner layer's framing byte, carried transparently in the payload. - The control channel is handler-internal. TTY sub-demuxes control
from its io
BiStreamusing 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/channelsruns another channels demux on itsBiStream. 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-075):
ChannelsAdapter— implementsProtocolHandlerforalknet/channels. Itshandle()receives oneConnection, reads 8-byte chunk headers, and routes chunks to theChannelManager. The read/demux half.ChannelManager— the shared state. Holdschannel_id → ChannelState, theHandlerRegistryreference, and theOperationRegistryreference. The reassemble/allocate half. What thechannel/openoperation handler closes over.
Each channel is presented to its handler as a Connection constructed via
Connection::from_source(ChannelBidiStreamSource, alpn) (ADR-070/074, as
amended by ADR-093). 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 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-079)
worker crates (any crate that dials a hub — consumes channels)
└── alknet-channels-call (ChannelClient — ADR-080)
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-072), the four lifecycle operations
(ADR-073) registered on the call protocol's OperationRegistry, and
ChannelClient (ADR-080). 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-079) 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-081 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-065/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-064) is the channels payload; the
channels layer carries it transparently.
What changes: the call protocol gains a new class of operations — channel
lifecycle (ADR-073). 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, reversed by ADR-093): 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-079) 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/.
| ADR | Decision | Summary |
|---|---|---|
| 071 | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no stream_type concept; one-way door |
| 093 | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no stream_type, into_sub_streams removed, BiStream-only, TTY always 5-byte |
| 072 | Channel 0 Pre-Negotiated | Channel 0 = alknet/call |
| 073 | Channel Lifecycle Operations | channel/open/close/control/resources/subscribe; subscribe not poll; direction pinned |
| 074 | ChannelConnection | Per-channel BidiStreamSource; yield-once accept_bi (amended by ADR-093 — into_sub_streams removed) |
| 075 | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| 076 | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| 077 | TTY Inside Channels | Two modes (direct vs channels); reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently |
| 078 | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| 079 | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| 080 | ChannelClient | Client side; transport-agnostic from_connection primary; connect_quic removed per ADR-089 §5; AlknetClient dial-seam extracted (ADR-089, resolves OQ-55) |
| 081 | 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. Key questions affecting this crate:
- OQ-55 (resolved by ADR-089):
AlknetClientcore dial+TLS seam — extracted asalknet-clientwith three dial methods.ChannelClient's API is transport-agnostic (from_connection); the dial is the shared seam, now extracted. See ADR-089. - OQ-56 (deferred(scope)): Full channel-level flow-control windowing — bounded-buffer is decided (ADR-076); 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-093); the function surface is not.