The ported docs still framed alknet siblings (alknet-core/-call/-channels/
-http/-tls/-client) as live load-bearing context of this crate. With the
extraction complete and alknet headed for glue-level replacement, the docs
now speak as alkcall and cite the real sibling crates (alkhttp, alktls,
alktty, alktunnels, alktrader).
- ADR-054 (new): re-own the verifier-selection rule in-crate — three
remote roles (public X.509 endpoint / transport relay / hub),
ServerCertVerifier by PeerEntry presence, RemoteIdentity None
load-bearing. Provenance: alknet ADR-034 (never ported), demoted to
footnote. Resolves OQ-21's cross-repo authority gap.
- OQ-43 (new, deferred(scope)): composition-layer ownership and shape
after the alknet replacement — the named receiver for the
downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
substrate, OQ-42 hub broker, OQ-19 persistence adapters, ADR-045 dial
seam). README layering diagram redrawn with the composition layer as
TBD-ownership instead of "alknet / alknode" in concrete.
- Spec docs reframed: titles, self-references, dial/TLS/from_jsonschema
homes, malformed legacy cite rows removed; dead relative research
paths qualified as source-repo paths.
- ADR amendments (append-only): 027 (alknet-http -> alkhttp), 031 (post-
extraction state), 044 (split landed as modules in alkcall), 045
(name normalization; citation-space note).
- Citation hygiene: unprefixed/colliding source-repo cites prefixed
("alknet ADR-NNN (provenance)") across 009/011/022/034/035/038/040/
043/044/045; stale OQ cites corrected (session registries OQ-04, batch
semantics OQ-03, verifier rule ADR-054); stale anchors fixed.
- README provenance note consolidated; citation conventions documented.
Verification: markdown link check (all local links resolve, all in-crate
ADR cites point at existing decisions/ files); table integrity check
(uniform row widths across open-questions.md and edited tables); residual
alknet-mention sweep (only provenance/historical/intentional framing
remains); architecture review subagent pass with zero remaining criticals.
Docs-only change — no code paths touched.
15 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-10-13 (alkcall reframing) |
alkcall channels — Overview
What
The channels subsystem of alkcall is a multiplexing proxy. It implements
ProtocolHandler for the alk/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 alk/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, the alk stack 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 alk/channels, one connection carries everything:
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alk/channels alk/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:
- 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
alk/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-035).
- Every channel is a
BiStream.accept_bi()yields oneBiStreamper 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_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— the TTY wire format, alknet ADR-052 amended by Phase 7; TTY is now the sibling cratealktty). The channels layer doesn't carry control. - Recursive composition is literal. A channel with ALPN
alk/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-039):
ChannelsAdapter— implementsProtocolHandlerforalk/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-008 for the
extension point, ADR-007 for the Connection constructors, 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 for the full adapter/manager design.
Crate dependencies
alkcall is a single crate with two internal subsystems (call + channels). The channels subsystem is split into two modules:
channels(core) — the pure multiplexer: wire format, demux/mux,ChannelBidiStreamSource,ChannelManager. ALPN-blind, call-protocol-blind, transport-blind. This is where the "streams are streams" insight lives.channels(call coupling) — channel 0 pre-negotiation asalk/call(ADR-036), the lifecycle operations (ADR-037) registered on the call protocol'sOperationRegistry,ChannelCore(ADR-047 §3), andChannelClient(ADR-043). This is where the call-protocol coupling lives, isolated from the pure multiplexer.
Downstream crates depend on alkcall as a single dependency:
alktty / alktunnels / alktrader (protocol crates)
└── alkcall (call + channels protocols, no networking)
composition layer (dial/accept + hub — today the alknet glue,
being replaced; future owner and shape → OQ-43)
├── alkcall
├── alktty (optional — whichever protocol crates are wired in)
├── alktunnels (optional)
└── quinn / tokio-rustls (transport)
The hub and worker are consumers of alkcall, not sub-crates. The
composition layer IS the hub — it depends on alkcall and uses
channels as its substrate, with the relay logic (ADR-042) living
alongside its 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.
ALPN
alk/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 | alk/channels ALPN on a QUIC connection; one bidi stream carries all channels |
| TCP+TLS | alk/channels ALPN on a TLS connection; the TCP stream carries all channels |
| WebTransport | alk/channels session (deferred per alknet ADR-044 — websocket-native session, provenance — the browser path uses WebSocket carrying alk/channels) |
| SSH channel | channels connection riding inside an SSH direct-tcpip channel (channels-over-SSH) |
| Another channels connection | recursive composition (channel type alk/channels inside alk/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-
039) — 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 dep graph is 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 downstream crates
alkcall is a pure protocol crate — no networking, no transport dependencies. Downstream crates compose on top of it. See README.md §"Roles and Composition" for the full dependency layering and the producer/consumer/hub/spoke role definitions.
Protocol crates (alktty, alktunnels, etc.)
Protocol crates depend only on alkcall. They provide:
- Producer half — a
register_*()function that takes an&mut OperationRegistryand registers ops with their handlers. For channels-based protocols, anOpenHandlerfactory registered viaChannelCore::register_openable. - Consumer half — a typed client wrapper around
CallConnection(orChannelClient) that exposes the crate's ops as async methods.
A protocol crate can support two paths for its wire format:
| Path | How it connects | Wire format |
|---|---|---|
| Direct ALPN | ProtocolHandler on its own ALPN (e.g. alk/tty), gets a Connection, loops accept_bi |
Own wire format (e.g. TTY's 5-byte header) |
| Through channels | Registered via ChannelCore::register_openable, gets a BiStream per session |
Own wire format rides inside channels 8-byte payload |
The protocol crate doesn't know which path it's on — it takes a
BiStream (or AsyncRead + AsyncWrite) and drives the session. The
two adapters are thin and live either in the protocol crate (behind
feature flags) or in the composition layer.
Channel 0 is the call protocol
The call protocol runs on channel 0 exactly as on a top-level
alk/call connection. The Dispatcher 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.
Hub (composition layer)
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 per-ALPN open ops 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/.
| ADR | Decision | Summary |
|---|---|---|
| 034 | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no stream_type concept; one-way door |
| 035 | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no stream_type, into_sub_streams removed, BiStream-only, TTY always 5-byte |
| 036 | Channel 0 Pre-Negotiated | Channel 0 = alk/call |
| 037 | Channel Lifecycle Operations | channel/open/close/control/resources/subscribe; subscribe not poll; direction pinned |
| 038 | ChannelConnection | Per-channel BidiStreamSource; yield-once accept_bi (amended by ADR-035 — into_sub_streams removed) |
| 039 | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| 040 | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| 041 | Per-Identity Channel Cap | 256 per PeerId, enforced via ChannelLifecyclePolicy; per-connection max_channels reframed as a memory bound |
| 042 | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| 043 | ChannelClient | Client side; transport-agnostic from_connection primary; dial lives in the composition layer's dial helper (ADR-045) |
| 044 | Sub-Crate Decomposition | channels-core (pure multiplexer) / channels-call (call coupling + ChannelClient); hub and worker are consumers |
| 047 | Openable ALPNs Are Operations | channel/open dissolves into per-ALPN ops; ChannelCore wrapper; connection-owner allocates channel_id |
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting this crate:
- Dial seam (ADR-045; source: alknet OQ-55): the dial+TLS seam is
the composition layer's dial helper (
AlknetClienttoday — future owner and shape OQ-43).ChannelClient's API is transport-agnostic (from_connection); the dial is the shared seam. See ADR-045. - OQ-23 (tracker; source: alknet 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.
- Two-pump helper (source: alknet OQ-57) (dissolved): the
contract is decided (the in-tree helper landed as
channels::pump_bidi, ADR-050); the extraction target (a mono-repo core crate) no longer exists — alkcall owns its helpers. - OQ-24 (tracker; source: alknet OQ-68) (resolved): 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 shipped surface integrates the strip (see OQ-24 in open-questions.md).