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

302 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 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:
```rust
// 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](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](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/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | 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](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)