Files
alkcall/docs/architecture/channels-overview.md
T
glm-5.3-flash 5f019c02f1 docs: re-key architecture docs off the alknet mono-repo — speak as alkcall
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.
2026-10-13 10:53:47 +00:00

15 KiB
Raw Blame History

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:

  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 alk/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 — the TTY wire format, alknet ADR-052 amended by Phase 7; TTY is now the sibling crate alktty). The channels layer doesn't carry control.
  • Recursive composition is literal. A channel with ALPN alk/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 alk/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 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 as alk/call (ADR-036), the lifecycle operations (ADR-037) registered on the call protocol's OperationRegistry, ChannelCore (ADR-047 §3), and ChannelClient (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 OperationRegistry and registers ops with their handlers. For channels-based protocols, an OpenHandler factory registered via ChannelCore::register_openable.
  • Consumer half — a typed client wrapper around CallConnection (or ChannelClient) 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 (AlknetClient today — 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).