Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
14 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-18 |
channels-wire.md — The 8-Byte Chunk Format
The wire format for alknet/channels: an 8-byte chunk header that
multiplexes N logical channels over a single ordered, reliable
bidirectional transport stream. ADR-034 (amended by ADR-035) is the
decision; this doc specifies the format and the wire-level invariants.
The channels layer has no stream_type concept — not in its header, not
in its code, not in its mental model. The handler owns its sub-stream
multiplexing on the BiStream the channels layer gives it.
Chunk header
[channel_id: u32 BE][length: u32 BE][payload bytes]
8 bytes of header, followed by length bytes of opaque payload.
| field | offset | width | meaning |
|---|---|---|---|
channel_id |
0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as alknet/call (ADR-036). Channels 1..N are opened dynamically via channel/open (ADR-037). |
length |
4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max MAX_CHUNK_LEN. |
The payload is opaque to the channels layer. The handler parses its own
framing from the payload — TTY's [stream_type:u8][length:u32][payload]
(5-byte format, ADR-052), call's length-prefixed JSON (EventEnvelope
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
How the wire formats compose
The channels 8-byte header and the handler's framing compose by layering:
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
The channels layer reads its 8-byte header (channel_id + length),
reads length bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (stream_type + length) from the payload bytes.
The two length fields are close but not identical: ch_len = tty_len + 5.
This is a small amount of waste per chunk (the channels length is always
5 bytes more than TTY's length), but the trade-off is clean separation
of concerns: the channels layer has no stream_type concept — not in
its header, not in its code, not in its mental model. The handler owns
its framing entirely. See ADR-035 for the full cost/benefit analysis.
MAX_CHUNK_LEN
16 * 1024 * 1024 (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
length > MAX_CHUNK_LEN returns ChunkTooLarge and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 8 bytes, so the demux can always resync by reading the next
8-byte header.
Channel 0 — pre-negotiated alknet/call
Channel 0 is not a special "control plane" with its own framing. It is
alknet/call pre-negotiated (ADR-036): both sides know channel_id = 0
is routed to the CallAdapter without an explicit channel/open
exchange.
Channel 0's chunks have channel_id = 0 in the 8-byte header — same
format as every other channel. The call protocol's EventEnvelope JSON
framing (ADR-014) is the payload; the channels layer carries it
transparently. Disambiguation between channel 0 and data channels is by
channel_id, not by a special first-byte trick.
Framing disambiguation
The 8-byte header is always exactly 8 bytes. length is bounded by
MAX_CHUNK_LEN. The demux reads 8 bytes, parses the header, reads
length bytes of payload, and routes. If a chunk is dropped (e.g.,
ChunkTooLarge), the demux resyncs by reading the next 8-byte header —
the format is self-synchronizing.
There is no channels-layer framing-disambiguation trick beyond the fixed
8-byte header. The channels layer does not interpret the payload — it
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
Any framing disambiguation within the payload is the handler's concern
(see tty-wire.md §"Framing disambiguation" for TTY's first-byte trick,
which is internal to TTY's 5-byte format).
Zero-length sentinel = EOF
A zero-length chunk (length = 0) is delivered as an empty payload,
which the reassembled stream interprets as EOF. This is the clean-shutdown
signal for a channel_id — the same convention as TTY (ADR-052
§Sentinels), now at the channels layer (one sentinel per channel, not
per (channel_id, stream_type)).
The sentinel is emitted by the write side's AsyncWrite::shutdown (see
REQ-CH-01 below) and consumed by the read side's AsyncRead::poll_read as
EOF.
Substrate modes — same wire format, different stream counts
The 8-byte header is used in all substrates, on every bidi stream. The difference between substrates is only how many bidi streams the transport yields:
| Substrate | Transport | Streams | Header role |
|---|---|---|---|
| In-line | TCP+TLS, WebTransport session, SSH direct-tcpip |
1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides channel_id correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The ChannelsAdapter::handle loop: accept_bi() → for each stream, read
the 8-byte header → route by channel_id → reassemble into a BiStream.
On an in-line transport, accept_bi() yields once then
ConnectionClosed — the header does all the demux. On QUIC, accept_bi()
yields repeatedly — each stream is a channel, and the header provides
channel_id correlation. Same code path, same wire format, same handler
experience. See ADR-034 §substrate modes (as amended by ADR-035), ADR-039.
Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (docs/research/alknet-channels/poc-summary.md §Issues
Surfaced) surfaced invariants that hang channels silently if underspecified.
These are contracts, not implementation details — both sides must agree.
REQ-CH-01: AsyncWrite::shutdown emits a zero-length sentinel
The reassembled stream's write half (MpscSendStream or equivalent) MUST
send an empty payload (the EOF sentinel) before dropping the sender on
AsyncWrite::shutdown. Without this, the demux never sees EOF on the
channel, and tokio::io::copy in the handler never
completes — the session hangs.
The TTY crate's pump_session emits the zero-length stdout sentinel
explicitly via its own 5-byte format's zero-length chunk; the channels
layer's per-channel write pump does NOT forward a sentinel on
sender-drop, so the send adapter must. Both sides must agree on this
convention, or channels hang on clean shutdown.
REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
The demux loop MUST clear its channels map on transport EOF, dropping
all ReassemblyBuffer senders. Every handler's reassembled BiStream
sees EOF even without an explicit zero-length sentinel arriving on the
wire.
Without this, read_to_end / tokio::io::copy in handlers hangs forever
waiting for a sender that never drops because the demux task is holding the
map. This is a teardown invariant of the ChannelsAdapter::handle contract.
REQ-CH-04: lenient unknown-channel_id handling with error counter
A chunk with an unallocated channel_id is dropped with a debug log and
an error counter (exposed via Demux::stats()), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown (a chunk for a channel that was just closed may arrive after
the close is processed).
The alternative (strict — close the transport on unknown channel_id) is
fragile during teardown and catches bugs at the cost of reliability. The
lenient approach with an error counter provides observability without
fragility.
REQ-CH-05: bounded-buffer backpressure does not deadlock
Each channel_id has an independent bounded mpsc buffer (default 1 MiB
— ADR-040). A slow reader on one channel does not block another channel's
reads — the demux's per-chunk route awaits the matching sender without
holding a global lock.
The 1 MiB tunnel_large_payload POC test exercised this end-to-end: a
channel writer faster than the TCP echo server consumer, with no deadlock
and no cross-channel blocking. This invariant must hold for all transport
shapes — the bounded-buffer approach is the decision (ADR-040).
Sync core / async shell split
The wire format's core is pure byte manipulation:
// wire.rs — sync core, no async, no platform deps, WASM-clean
const CHUNK_HEADER_LEN: usize = 8;
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
pub struct ChunkHeader {
pub channel_id: u32,
pub length: u32,
}
pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
The async shell (demux/mux — see channels-adapter.md)
wraps this core with read_exact / write_all on the transport and mpsc
routing. The split keeps the WASM-compatible core separate from the
tokio-dependent shell. The POC validated the sync core compiles under
wasm32-unknown-unknown.
The add/strip composition
Each layer has its own add/strip pair. The channels layer:
add_channel_id(channel_id, payload_bytes) -> chunk on write (prepends
the 8-byte header); strip_channel_id(chunk) -> (channel_id, payload_bytes) on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing wire.rs. The handler doesn't know or care that
a channel_id was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A alknet/channels-inside-alknet/channels
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
The exact API shape of the add/strip pair (built into the read/write path vs. a standalone utility) is an implementation detail for the channels crate, tracked as OQ-68. The contract — the channels layer strips its 8-byte header on read and the handler parses its own framing from the payload — is decided; the function surface is not.
Channel lifecycle (summary)
| Phase | Mechanism | Reference |
|---|---|---|
| Open | channel/open call operation on channel 0; responder allocates channel_id, returns it |
ADR-037 |
| Data | chunks with channel_id routed to reassembly buffers; handler sees a BiStream |
this doc, channels-connection.md |
| Control (out-of-band) | channel/control call operation on channel 0 |
ADR-037 |
| Close | channel/close call operation on channel 0; data chunks flushed before close |
ADR-037, REQ-CH-06 |
REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
The channel's data chunks MUST be written and flushed before the
channel/close operation is sent on channel 0. This is a wire-level
invariant: the side closing must observe the data-channel pump complete
before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
forward: the exit control message (on TTY's STREAM_CTRL_OUT stream_type
4, inside TTY's 5-byte payload) is the last data before channel/close.
For tunnels it is the last data byte before close. The channels layer's
close handler observes the pump completion; the call operation is issued
after.
This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler
concern. The control-message division (data-ordered control vs
out-of-band control) is now entirely handler-internal: TTY's
STREAM_CTRL_IN / STREAM_CTRL_OUT are stream_types in TTY's 5-byte
payload format, not channels-layer concepts.
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-035); 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 |
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting this doc:
- 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; the function surface is not.
References
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
header, no
stream_type) - ADR-035: channels pure channel multiplexing (the umbrella decision that amends ADR-034/074/077)
- ADR-052: alknet-tty wire format (the 5-byte format carried transparently in the channels payload)
- ADR-036: channel 0 pre-negotiated
- ADR-037: channel lifecycle operations
- ADR-040: backpressure, channel limits, ID reuse
docs/research/alknet-channels/poc-summary.md§POC Target 1, §Issues Surfaced #4-#6 (REQ-CH-01, 02, 04)docs/research/stream-unification/findings.md— the research that surfaced the 8-byte format decisioncrates/alknet-tty/src/wire.rs— the 5-byte format implementation (carried transparently in the channels payload)