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).
311 lines
16 KiB
Markdown
311 lines
16 KiB
Markdown
# ADR-034: alknet-channels Wire Format — 8-Byte Chunk Header
|
||
|
||
## Status
|
||
|
||
Accepted (revised 2026-07-12: substrate simplification + stream_type
|
||
decomposition; **amended 2026-07-18 by ADR-035: wire format is 8 bytes,
|
||
not 9; `stream_type` removed from the channels header — see "Amendment
|
||
(ADR-035, 2026-07-18)" below**)
|
||
|
||
## Amendment (ADR-035, 2026-07-18)
|
||
|
||
The 9-byte chunk header is **amended to 8 bytes**:
|
||
`[channel_id:u32 BE][length:u32 BE][payload]`. The `stream_type` byte is
|
||
**removed** from the channels header — 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 (per ADR-035, the channels-layer
|
||
consequence of ADR-009's `BiStream` handler leaf). What was the channels
|
||
header's `stream_type` byte is now the first byte of the payload, owned
|
||
by the handler's framing (TTY's 5-byte format, call's length-prefixed
|
||
JSON, tunnel's raw bytes, SSH's channel protocol).
|
||
|
||
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
||
groups) is **removed from the channels layer**. The stream_type concept
|
||
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
|
||
channels layer carries transparently in its payload. The total header
|
||
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
||
9; the two length fields are close but not identical
|
||
(`ch_len = tty_len + 5`). This is the documented cost of clean
|
||
separation of concerns — see ADR-035 §"Consequences" for the full
|
||
cost/benefit.
|
||
|
||
The body below describes the **current** (9-byte) shape; the amendment
|
||
above is the operative decision. The 9-byte description is kept as the
|
||
historical context for the amendment. See ADR-035 for the resolution
|
||
rationale and the cross-ADR impacts.
|
||
|
||
## Context
|
||
|
||
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
|
||
`alknet/channels` that carries N logical channels, each with a different
|
||
ALPN, over transport stream(s). The wire format is the substrate that makes
|
||
this work.
|
||
|
||
Two prior formats inform this design:
|
||
|
||
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
|
||
string-named types negotiated per channel, all traffic interleaved on one
|
||
encrypted transport stream.
|
||
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
|
||
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
||
within one bidi stream. Validated by two POCs and in production code.
|
||
|
||
The channels format is the generalization: add a `channel_id: u32` prefix to
|
||
TTY's 5-byte header, turning a fixed 4-channel multiplexer into an arbitrary
|
||
N-channel multiplexer. The de-risk POC (28 tests) validated the core
|
||
mechanics.
|
||
|
||
### The substrate simplification
|
||
|
||
Different transports have different native multiplexing capabilities. QUIC
|
||
has native bidi streams; TCP+TLS does not. The initial framing of this ADR
|
||
treated the 9-byte header as "the in-line substrate format" — used when the
|
||
transport has no native multiplexing, with a separate substrate for QUIC
|
||
native streams. That framing adds complexity (two substrates, a
|
||
`channel_id`↔stream-ID mapping) for no benefit.
|
||
|
||
The simplification: **the 9-byte header is used in all substrates, on every
|
||
bidi stream that carries channels.** The `channel_id` in the header is the
|
||
logical correlation key. The transport's native multiplexing (when present)
|
||
is a performance optimization (independent flow-control windows per
|
||
stream), not a protocol change. The `ChannelsAdapter` reads the 9-byte
|
||
header off every bidi stream it accepts — on a transport with one stream
|
||
(in-line), the header demuxes N channels from that stream; on a transport
|
||
with N streams (QUIC native), each stream carries one logical channel and
|
||
the header provides `stream_type` and `channel_id` correlation. Same code
|
||
path, same wire format, same handler experience.
|
||
|
||
### The stream_type decomposition
|
||
|
||
The initial framing had `stream_type` 3 as "bidirectional" (control
|
||
messages). This is a design flaw: one stream_type both sides write to is not
|
||
properly multiplexed — it loses independent flow control, independent EOF,
|
||
and clean separation of concerns. The TTY crate's control channel already
|
||
exhibits this problem ("the control channel isn't actually bidirectional…
|
||
the adapter ignores Exit from the client" — phase-0 findings §Less
|
||
Straightforward Parts).
|
||
|
||
The fix: **every stream_type is unidirectional.** Bidirectionality is
|
||
achieved by having two stream_types (one write, one read), the same way
|
||
QUIC bidi streams are two unidirectional halves. Control becomes 3 (write,
|
||
client→server) and 4 (read, server→client), not one "bidirectional" 3.
|
||
|
||
## Decision
|
||
|
||
### Chunk header
|
||
|
||
```
|
||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
||
```
|
||
|
||
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
||
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
|
||
preserving the framing-disambiguation soundness property.
|
||
|
||
| field | width | meaning |
|
||
|-------|-------|---------|
|
||
| `channel_id` | u32 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`. |
|
||
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
||
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
|
||
|
||
### `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 9 bytes, so the demux can always resync by reading the next 9-byte
|
||
header.
|
||
|
||
### Stream types — unidirectional, grouped in threes
|
||
|
||
**Every stream_type is unidirectional.** Bidirectionality is two
|
||
stream_types (write + read), not one "bidirectional" stream_type. The
|
||
stream_types are grouped in threes:
|
||
|
||
| Group | stream_type | direction | purpose |
|
||
|-------|-------------|-----------|---------|
|
||
| Data | 0 | write (client→server) | data in (stdin equivalent) |
|
||
| | 1 | read (server→client) | data out (stdout equivalent) |
|
||
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
|
||
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
|
||
| | 4 | read (server→client) | control out (ALPN-specific format) |
|
||
| | 5 | read (server→client) | control err (optional) |
|
||
| Future | 6/7/8 | write/read/read | next group, same pattern |
|
||
| | 9/10/11 | write/read/read | next group |
|
||
| | ... | | |
|
||
|
||
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
|
||
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
|
||
|
||
256 values / 3 = 85 groups. The `u32` channel_id space (~4 billion channels
|
||
before wrap, ADR-040) combined with 85 stream_type groups is effectively
|
||
unlimited for the intended use cases.
|
||
|
||
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
|
||
own flow control, its own EOF. The TTY control channel becomes *actually*
|
||
bidirectional because there are two unidirectional streams (3 in, 4 out),
|
||
not one stream both sides write to. This resolves the "control channel
|
||
isn't actually bidirectional" problem the TTY crate has today. The same
|
||
principle applies to any future channel type — control is two halves, not
|
||
one shared stream.
|
||
|
||
**Control payload format is ALPN-specific, not channels-enforced.** The
|
||
channels layer is blind to what stream_types 3/4/5 carry — it reassembles
|
||
bytes and delivers them to the handler. The TTY crate happens to use JSON
|
||
for its control channel (resize, signal, eof, exit) because its control
|
||
messages map cleanly to JSON; another ALPN might use a binary control
|
||
format. The channels layer does not mandate JSON on control stream_types.
|
||
This is the same ALPN-blindness principle that applies to the data
|
||
stream_types: the channels layer routes bytes, the handler interprets them.
|
||
|
||
### Per-ALPN stream_type sets
|
||
|
||
| ALPN | Active stream_types | Why |
|
||
|------|---------------------|-----|
|
||
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
|
||
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
|
||
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
|
||
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
|
||
|
||
The active set is declared at `channel/open` time (ADR-037 `stream_types`
|
||
field) and fixed for the channel's lifetime. A tunnel that wants keepalive
|
||
could declare [0, 1, 3, 4].
|
||
|
||
### Substrate modes — same wire format, different stream counts
|
||
|
||
The 9-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 `stream_type` + `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 9-byte header → route by `(channel_id, stream_type)` → reassemble. 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 `stream_type` and
|
||
`channel_id` correlation. Same code path, same wire format, same handler
|
||
experience.
|
||
|
||
**Why keep the header on QUIC when the stream already separates channels:**
|
||
- **`stream_type` decomposition.** TTY needs 5 sub-streams. On QUIC, one
|
||
stream per channel + the header carrying `stream_type` is simpler than 5
|
||
streams per channel and matches the in-line case's shape. Per-stream-type
|
||
flow control is handled at the reassembly buffer level (ADR-040)
|
||
regardless of substrate.
|
||
- **`channel_id` correlation.** The hub relay forwards a channel from the
|
||
browser leg to the spoke leg, mapping `browser_id ↔ spoke_id`. If
|
||
`channel_id` is in the header on every stream, the relay correlates by
|
||
reading the header — regardless of substrate. If `channel_id` lived only
|
||
in the `channel/open` response, the relay would need a stream-ID-to-
|
||
`channel_id` mapping per leg.
|
||
- **Uniform handler experience.** The handler receives a `Connection` and
|
||
calls `accept_bi()` or `into_sub_streams()`. It doesn't know or care
|
||
whether the substrate is in-line or native — the
|
||
`ChannelBidiStreamSource` wraps the reassembled stream either way.
|
||
|
||
The "bloat" (9 bytes/chunk on transports that have native multiplexing) is
|
||
the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
|
||
operations), this is noise. The high-throughput escape hatch is
|
||
multi-connection, not stripping the header.
|
||
|
||
### Framing disambiguation (carried from ADR-052 §5)
|
||
|
||
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
||
header. Disambiguation between channel 0 (call protocol) and data channels
|
||
is by `channel_id`, not by a special first-byte trick. Within a channel,
|
||
`stream_type` 0 (write half) from the server is invalid, so `0x00` as the
|
||
first byte of a chunk payload from the server is unambiguous.
|
||
|
||
### Zero-length sentinel = EOF
|
||
|
||
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
|
||
stream interprets as EOF (same convention as TTY — ADR-052 §Sentinels). This
|
||
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
||
|
||
### Sync core / async shell split
|
||
|
||
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9])
|
||
-> ChunkHeader` and `write_header(channel_id, stream_type, length, &mut
|
||
[u8; 9])`. No async, no platform dependencies. Compiles under
|
||
`wasm32-unknown-unknown` (validated by the POC). The async shell (demux/mux)
|
||
wraps this core with `read_exact`/`write_all` on the transport and `mpsc`
|
||
routing. This split keeps the WASM-compatible core separate from the
|
||
tokio-dependent shell.
|
||
|
||
## Consequences
|
||
|
||
**Positive:**
|
||
- One wire format across all substrates. The `ChannelsAdapter` code path is
|
||
the same regardless of transport; the transport's native multiplexing is a
|
||
performance optimization, not a protocol change.
|
||
- Every stream_type is unidirectional — proper multiplexing with independent
|
||
flow control and EOF per half. The TTY control channel becomes actually
|
||
bidirectional (3 in, 4 out), resolving the "not actually bidirectional"
|
||
flaw.
|
||
- The hub relay correlates by `channel_id` in the header, uniformly across
|
||
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
||
- WASM-compatible by construction — the pure core has no platform deps.
|
||
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
||
|
||
**Negative:**
|
||
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
||
already separates streams. This is 4 bytes more than using the QUIC stream
|
||
ID directly as the `channel_id`. For the intended use cases this is noise;
|
||
for high-throughput bulk transfer, the answer is multi-connection, not
|
||
stripping the header.
|
||
- All channels on one in-line connection share one transport stream's
|
||
flow-control window. A slow consumer on one channel can backpressure
|
||
others. Mitigated by bounded-buffer backpressure (ADR-040), not
|
||
eliminated. The native substrate (QUIC streams) avoids this — each
|
||
channel gets its own flow-control window. For high-throughput, use native
|
||
or multi-connection.
|
||
|
||
## Door type
|
||
|
||
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
|
||
length:u32`, 9 bytes) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
|
||
control, `% 3` formula) are wire-format commitments. Changing them after
|
||
deployments exist requires a version migration.
|
||
|
||
**Amended by ADR-035 (2026-07-18):** the header layout is now
|
||
`channel_id:u32 + length:u32` (8 bytes); the `stream_type` byte and its
|
||
decomposition are removed from the channels layer. The one-way door is
|
||
re-cast (the channels crate is not yet implemented, so this is the right
|
||
time to cast it). See ADR-035 for the amended door-type discussion.
|
||
|
||
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
|
||
within the one-way format.
|
||
|
||
## References
|
||
|
||
- **ADR-035**: channels pure channel multiplexing (amends this ADR —
|
||
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
||
header; the stream_type decomposition is removed from the channels
|
||
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
|
||
TTY always uses its 5-byte format, carried transparently in the
|
||
channels payload**)
|
||
- ADR-007: `Connection::from_stream` (the transport-agnostic Connection)
|
||
- ADR-008: `BidiStreamSource` trait (the extension point the channels
|
||
connection implements; its docstring already anticipated per-channel
|
||
streams)
|
||
- ADR-036: channel 0 pre-negotiated (now uses stream_types [0, 1])
|
||
- ADR-037: channel lifecycle operations (stream_types field examples
|
||
updated)
|
||
- ADR-038: ChannelBidiStreamSource (into_sub_streams returns unidirectional
|
||
handles)
|
||
- ADR-040: backpressure (bounded-buffer applies at reassembly regardless of
|
||
substrate)
|
||
- ADR-077: TTY inside channels (5 sub-streams; control properly
|
||
bidirectional via 3/4)
|
||
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated
|
||
the format (28 tests, WASM compile check)
|
||
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format, §Less
|
||
Straightforward Parts (the control-channel bidirectionality problem) |