Files
alkcall/docs/architecture/channels-wire.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
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).
2026-08-12 07:06:57 +00:00

14 KiB
Raw Blame History

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 decision
  • crates/alknet-tty/src/wire.rs — the 5-byte format implementation (carried transparently in the channels payload)