phase 4: architecture docs + BAST schema + renumbered ADRs
Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
# ADR-008: Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amends alknet ADR-071 — wire format is 8 bytes, not 9, and
|
||||
the channels layer has no `stream_type` concept; amends alknet ADR-074
|
||||
— `into_sub_streams()` removed, `accept_bi` is the only accessor and
|
||||
yields one `BiStream` per channel; reverses [ADR-007](007-tty-inside-channels.md)
|
||||
— TTY always uses its 5-byte format, the channels layer carries it
|
||||
transparently in the payload. Ported from alknet ADR-093 2026-08-17;
|
||||
cross-references renumbered to alktty's ADR range — ADR-052→001,
|
||||
ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006,
|
||||
ADR-077→007, ADR-093→008. The alknet ADRs referenced by alknet number
|
||||
(052, 070, 071, 072, 073, 074, 075, 076, 078, 079, 080, 081, 092) are
|
||||
not ported into alktty's ADR range — they are alknet-core /
|
||||
alknet-channels ADRs. The alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
alknet ADR-071 committed the channels wire format as a 9-byte chunk
|
||||
header (`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte
|
||||
extension of TTY's 5-byte format, with `stream_type` carried in the
|
||||
channels header and decomposed into unidirectional halves (0/1/2 =
|
||||
data write/read/err, 3/4/5 = control write/read/err, `% 3` formula).
|
||||
alknet ADR-074 added a second accessor (`into_sub_streams()`)
|
||||
alongside `accept_bi` for handlers that need typed sub-streams (TTY's
|
||||
stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md)
|
||||
split TTY's wire format into two modes — direct (5-byte) and
|
||||
inside-channels (the channels layer de-chunks and the adapter
|
||||
destructures via `into_sub_streams()`).
|
||||
|
||||
The stream-unification research surfaced that these three decisions
|
||||
share one root: the channels layer carries a concept (`stream_type`)
|
||||
it doesn't own. The 9-byte header bakes TTY's sub-stream multiplexing
|
||||
into the channels wire format. The `into_sub_streams()` accessor exists
|
||||
because the channels layer reassembles per-`stream_type` and needs to
|
||||
expose the result. The two-mode TTY design exists because the channels
|
||||
layer's `stream_type` overlaps with TTY's own `stream_type`. The mod
|
||||
2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071
|
||||
revised) was a symptom of this overlap — a numbering convention for a
|
||||
concept the channels layer shouldn't carry.
|
||||
|
||||
### The structural question
|
||||
|
||||
The channels layer has two objectives in tension:
|
||||
|
||||
1. **"Pass a stream to/from any ALPN"** — every channel is a
|
||||
`BiStream`; any handler gets `accept_bi()` and treats the channel
|
||||
as a duplex stream. Uniform, transport-agnostic,
|
||||
recursive-composition-friendly.
|
||||
2. **"Channels carry N sub-streams"** — a TTY channel carries
|
||||
stdin/stdout/stderr/control; the handler destructures via
|
||||
`into_sub_streams()`. Carries what the source produces.
|
||||
|
||||
The tension is real when a sub-stream is *unidirectional* (stderr). You
|
||||
can't represent stderr as a `BiStream` without wasting the write half;
|
||||
you can't make it a "third half" (mod 3) without breaking pair
|
||||
symmetry; you can't make the channel a single `BiStream` without
|
||||
losing the stdout/stderr distinction.
|
||||
|
||||
alknet ADR-074's two-accessor design resolves this by making the "pass
|
||||
a stream to/from any ALPN" objective *qualified* — it applies to
|
||||
single-stream channels (tunnel, SSH, call), not multi-stream channels
|
||||
(TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified
|
||||
design.
|
||||
|
||||
### The resolution: 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.
|
||||
|
||||
- **Every channel is a `BiStream`.** `accept_bi()` yields one
|
||||
`BiStream` per channel (per alknet ADR-092, already landed). No
|
||||
`into_sub_streams()`, no second-class accessor.
|
||||
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
|
||||
sub-demuxes `stream_type` from its `BiStream` (its 5-byte format).
|
||||
Tunnel uses the `BiStream` as raw bytes. Call length-prefixes JSON.
|
||||
SSH runs its own channel protocol. The channels layer carries the
|
||||
bytes transparently.
|
||||
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.**
|
||||
The channels layer has no `stream_type` concept — not in its header,
|
||||
not in its code, not in its mental model. `stream_type` is the inner
|
||||
layer's framing byte, carried transparently.
|
||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
||||
`STREAM_CTRL_OUT = 4` — ADR-001 amended by Phase 7). The channels
|
||||
layer doesn't carry control. The "control isn't actually
|
||||
bidirectional" flaw is fixed at the TTY layer, not the channels
|
||||
layer.
|
||||
- **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. Each level is the same shape —
|
||||
`BiStream → accept_bi → N BiStreams`.
|
||||
|
||||
### The wire format decision: 8 bytes
|
||||
|
||||
The channels wire format is **8 bytes**:
|
||||
`[channel_id:u32 BE][length:u32 BE]` followed by an opaque payload. The
|
||||
channels layer owns `channel_id` and `length`; the payload is the
|
||||
handler's framing, carried transparently.
|
||||
|
||||
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
|
||||
was considered and rejected. The 9-byte format puts `stream_type` in the
|
||||
channels header, which means the channels layer carries a concept it
|
||||
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
|
||||
5-byte header with `channel_id` prepended), but for non-TTY handlers
|
||||
(tunnel, call, SSH) the `stream_type` byte is dead weight — the
|
||||
channels layer carries a byte it doesn't understand, and the handler
|
||||
ignores a byte in a header it doesn't control.
|
||||
|
||||
The 8-byte format is uniform across all handlers: the channels layer
|
||||
carries `channel_id` + `length` + opaque payload. Every handler parses
|
||||
its own framing from the payload. The cost is that TTY's `wire.rs` is
|
||||
called from a payload buffer rather than directly from the wire, and
|
||||
the total header for a TTY chunk is 13 bytes (8 channels + 5 TTY)
|
||||
instead of 9. The two length fields are close but not identical
|
||||
(`ch_len = tty_len + 5`); for typical TTY chunks (4 KiB+), the 5-byte
|
||||
overhead is ~0.1%, and the trade is clean separation of concerns. See
|
||||
"Consequences" for the full cost/benefit.
|
||||
|
||||
### 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 `alk/channels`-inside-`alk/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.
|
||||
|
||||
### Why this can land now
|
||||
|
||||
Three things changed since alknet ADR-071/074/077 were accepted:
|
||||
|
||||
1. **alknet ADR-092 landed `BiStream` as the handler leaf.**
|
||||
`accept_bi()` returns a `BiStream` (a concrete `AsyncRead +
|
||||
AsyncWrite` newtype), not a split `(SendStream, RecvStream)` pair.
|
||||
The join moves into core's quinn/iroh/stream impls (once per
|
||||
source, invisible to handlers). This ADR's "every channel is a
|
||||
`BiStream`" is the channels-layer consequence of alknet ADR-092's
|
||||
handler-leaf decision — the research-then-sync pattern applied:
|
||||
alknet ADR-092 settled the transport leaf, this ADR settles the
|
||||
multiplexing layer above it.
|
||||
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
|
||||
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it
|
||||
into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's
|
||||
5-byte format*, not at the channels layer. This removed the
|
||||
load-bearing reason for the channels layer to carry `stream_type`:
|
||||
the control bidirectionality fix is a TTY-internal concern, not a
|
||||
channels-layer concern. ADR-007's two-mode TTY design was motivated
|
||||
by the channels layer carrying control; with control moved inside
|
||||
TTY, the motivation dissolves.
|
||||
3. **No production constraint.** The develop branch is a rewrite of
|
||||
main (pre-alpha). The channels crate doesn't exist yet (per
|
||||
alknet ADR-081, it's planned as `alknet-channels-core` +
|
||||
`alknet-channels-call`). The decision is purely "what's cleanest,"
|
||||
not "what's least disruptive." The 9-byte POC validated the
|
||||
per-`channel_id`/`stream_type` routing mechanism; the 8-byte spec
|
||||
update changes the header before implementation begins.
|
||||
|
||||
### What this ADR does NOT decide
|
||||
|
||||
- **The add/strip API shape** (built into read/write vs. a separate
|
||||
utility): the stream-unification research proposed `add_channel_id`
|
||||
/ `strip_channel_id` as standalone functions. Ideally the header is
|
||||
built into the read/write path so the utility isn't needed at the
|
||||
handler boundary — but there may be a generalized reason to expose
|
||||
it (recursive composition, test helpers, the hub relay's
|
||||
`channel_id` rewrite). The exact API shape is an implementation
|
||||
detail for the channels crate. The *contract* — the channels layer
|
||||
strips its 8-byte header on read and the handler parses its own
|
||||
framing from the payload — is decided here; the *function surface*
|
||||
is not.
|
||||
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads
|
||||
from an `AsyncRead`. Adapting it to read from a payload buffer
|
||||
(`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change (the
|
||||
framing logic — stream_type constants, length validation, control
|
||||
message parsing — is unchanged). This is an implementation concern
|
||||
for the channels + TTY integration, not an architecture decision.
|
||||
In alktty, `drive_session` already runs against a `BiStream` whose
|
||||
`AsyncRead` yields the post-channels-strip payload bytes; the same
|
||||
`wire.rs` code runs in both modes.
|
||||
- **Full channel-level flow-control windowing (alknet OQ-56):**
|
||||
unchanged. The bounded-buffer backpressure (alknet ADR-076) is the
|
||||
v1 mechanism; full windowing is an additive extension that doesn't
|
||||
change the wire format.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The channels wire format is 8 bytes
|
||||
|
||||
```
|
||||
[channel_id: u32 BE][length: u32 BE][payload bytes]
|
||||
```
|
||||
|
||||
8 bytes of header, followed by `length` bytes of opaque payload. The
|
||||
channels layer owns `channel_id` and `length`; the payload is the
|
||||
handler's framing, carried transparently.
|
||||
|
||||
| field | offset | width | meaning |
|
||||
|-------|--------|-------|---------|
|
||||
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). |
|
||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-001 §5). |
|
||||
|
||||
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. 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).
|
||||
|
||||
This amends alknet ADR-071: the wire format is 8 bytes, not 9; the
|
||||
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups)
|
||||
is removed from the channels layer. The stream_type concept survives in
|
||||
TTY's 5-byte format (ADR-001, amended by Phase 7), which the channels
|
||||
layer carries transparently.
|
||||
|
||||
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
|
||||
|
||||
alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` /
|
||||
`SubStreamHandle` are removed. The channels layer exposes one accessor:
|
||||
`accept_bi()`, which yields one `BiStream` per channel (per alknet
|
||||
ADR-092). Every handler — TTY, tunnel, SSH, call — receives a
|
||||
`Connection`, calls `accept_bi()` once, gets a `BiStream`, and
|
||||
sub-multiplexes it however it wants.
|
||||
|
||||
This amends alknet ADR-074: the two-accessor design (`accept_bi` for
|
||||
generic handlers, `into_sub_streams` for typed handlers) collapses to
|
||||
one accessor. The "typed handler path" (alknet ADR-074's motivating
|
||||
case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its
|
||||
own 5-byte format — the same code TTY runs in direct mode. alknet
|
||||
ADR-074's yield-once `accept_bi` contract is preserved; the
|
||||
`into_sub_streams()` accessor is the amended part.
|
||||
|
||||
### 3. TTY always uses its 5-byte format; the channels layer carries it transparently
|
||||
|
||||
ADR-007's two-mode TTY design (direct vs inside-channels) is reversed.
|
||||
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-001)
|
||||
is TTY's internal format, used in *both* direct mode and
|
||||
inside-channels mode. The two modes differ only in *where the
|
||||
`BiStream` comes from* (a top-level `alk/tty` connection vs a
|
||||
`channel/open` with ALPN `alk/tty`), not in *how TTY parses it*. The
|
||||
same `wire.rs` code runs in both modes.
|
||||
|
||||
When TTY is inside channels, the channels layer strips its 8-byte
|
||||
header and hands TTY the payload bytes. TTY parses its 5-byte header
|
||||
from the payload. The channels layer carries TTY's 5-byte chunks
|
||||
transparently in its payload — no shared fields, no leaked
|
||||
abstraction, no double-chunking concern (the 13-byte total header is
|
||||
8 channels + 5 TTY, not 8 + 9; the channels `length` is always
|
||||
`tty_len + 5`).
|
||||
|
||||
This reverses ADR-007: the 5-byte format is NOT scoped to direct —
|
||||
it's TTY's internal format, carried transparently in the channels
|
||||
payload. The `channels` feature on alktty (if added) becomes "run
|
||||
TTY's sub-demux on a channels-backed `BiStream`" — the same code as
|
||||
direct mode, different `BiStream` source. The control channel split
|
||||
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
|
||||
channels layer doesn't know about it.
|
||||
|
||||
### 4. The add/strip composition
|
||||
|
||||
The channels layer's read path strips the 8-byte header and hands the
|
||||
payload to the handler. The write path prepends the 8-byte header
|
||||
(`add_channel_id`) onto the handler's output. The handler never sees
|
||||
the `channel_id`; it sees only its own framing (the payload bytes).
|
||||
|
||||
```
|
||||
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 composition is uniform — the same shape at every level. A
|
||||
`alk/channels`-inside-`alk/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.
|
||||
|
||||
### 5. What does NOT change
|
||||
|
||||
- **alknet ADR-092's `BiStream` leaf** — unchanged. This ADR is the
|
||||
channels-layer consequence of alknet ADR-092: `accept_bi` yields a
|
||||
`BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet
|
||||
ADR-092 settles the transport leaf; this ADR settles the
|
||||
multiplexing layer above it).
|
||||
- **`ProtocolHandler` trait shape** (alknet ADR-002) — unchanged.
|
||||
Handlers receive a `Connection` and call `accept_bi()`.
|
||||
- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072) —
|
||||
unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte
|
||||
header. The call protocol's `EventEnvelope` framing is the payload;
|
||||
the channels layer carries it transparently.
|
||||
- **Channel lifecycle operations** (alknet ADR-073) — unchanged. The
|
||||
four operations (`channel/open`/`close`/`control`/`resources/subscribe`)
|
||||
and their `direction` semantics are call-protocol operations on
|
||||
channel 0, not channels-wire-format concerns.
|
||||
- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075) —
|
||||
structurally unchanged. The demux loop reads 8-byte headers (not
|
||||
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
|
||||
transport-blind. The `stream_types` field on `channel/open` and
|
||||
`ChannelState` is removed (the channels layer doesn't track
|
||||
per-stream-type reassembly buffers; it tracks one reassembly buffer
|
||||
per `channel_id`, yielding a `BiStream`).
|
||||
- **Backpressure, channel limits, ID reuse** (alknet ADR-076) —
|
||||
unchanged. The bounded-buffer backpressure is per-`channel_id` (was
|
||||
per-`(channel_id, stream_type)`; now per-`channel_id` since there's
|
||||
one reassembly buffer per channel). The 256-channel cap, 1 MiB
|
||||
default, and monotonic-ID-with-wrap strategy are unchanged.
|
||||
- **Two-pump shutdown-on-completion** (alknet ADR-078) — unchanged.
|
||||
Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump
|
||||
halves; the shutdown-on-completion contract applies to the
|
||||
`ReadHalf` / `WriteHalf` unchanged.
|
||||
- **Hub relay** (alknet ADR-079) — unchanged in contract. The hub
|
||||
translates `channel/open` on channel 0 and byte-forwards data
|
||||
channels with `channel_id` rewrite. The relay reads 8-byte headers
|
||||
(not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite
|
||||
within the 8-byte header, not a 9-byte header). The relay does not
|
||||
parse the payload.
|
||||
- **`ChannelClient`** (alknet ADR-080) — unchanged in API.
|
||||
`from_connection` primary, `open_channel` returns a `Channel`. The
|
||||
`stream_types` field on `open_channel` and `Channel` is removed (the
|
||||
channels layer doesn't negotiate per-stream-type sets; the handler
|
||||
owns its sub-stream multiplexing). The
|
||||
`channel:stream_type_unavailable` error code is removed (the
|
||||
channels layer can't refuse a `stream_type` it doesn't know about).
|
||||
- **Sub-crate decomposition** (alknet ADR-081) — unchanged.
|
||||
`channels-core` (pure multiplexer, depends on alknet-core only) /
|
||||
`channels-call` (channel 0 pre-negotiation + lifecycle op
|
||||
registrations, depends on `channels-core` + alknet-call). The 8-byte
|
||||
wire format, demux/mux, and `ChannelBidiStreamSource` are in
|
||||
`channels-core`; the call-protocol coupling is in `channels-call`.
|
||||
- **`BidiStreamSource` trait** (alknet ADR-070) — unchanged in shape.
|
||||
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
|
||||
`BiStream` (per alknet ADR-092, already landed).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- **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. This
|
||||
dissolves the mod 2/mod 3/mod 4 question at the channels layer
|
||||
(there's nothing to decompose) and fixes the "control isn't actually
|
||||
bidirectional" TTY flaw at the TTY layer (where it lives, not the
|
||||
channels layer).
|
||||
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
|
||||
receive the same shape: a `BiStream`. No handler gets a
|
||||
`stream_type` byte it doesn't use; no handler needs a second
|
||||
accessor (`into_sub_streams`) to reach its sub-streams. The channels
|
||||
layer's API surface is `accept_bi -> BiStream`, period.
|
||||
- **Recursive composition is literal.** A `alk/channels` channel 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. Same code, same shape, each level. This is a property,
|
||||
not a feature — the primary use case is one level of multiplexing,
|
||||
but the add/strip composition makes the recursion cleaner than
|
||||
alknet ADR-071's group framing did.
|
||||
- **The `into_sub_streams()` accessor and its consuming handler code
|
||||
are removed.** This is a net simplification: one accessor, one
|
||||
handler path, no downcast / extension trait / "two paths" ergonomics
|
||||
question (which alknet ADR-074 left as an implementation detail).
|
||||
The handler crate destructures its `BiStream` via its own framing
|
||||
(TTY's 5-byte format), not via a channels-crate-provided typed
|
||||
accessor.
|
||||
- **TTY's `wire.rs` runs unchanged in both modes.** Direct mode and
|
||||
inside-channels mode use the same code; only the `BiStream` source
|
||||
differs. ADR-007's `drive_session_direct` / `drive_session_channels`
|
||||
split collapses to one `drive_session` function. The `channels`
|
||||
feature on alktty (if added) becomes a thin wrapper that gets the
|
||||
`BiStream` from a channels-backed `Connection` instead of a
|
||||
top-level one. (In alktty as built, `drive_session` already takes a
|
||||
generic `AsyncRead + AsyncWrite` pair, so both the direct
|
||||
`TtyAdapter::handle` path and the channels `TtyOpenHandler` path
|
||||
call the same function — see `src/adapter.rs` and
|
||||
`src/channels.rs`.)
|
||||
- **The channels layer is WASM-compatible by construction.** The
|
||||
8-byte header's core is pure byte manipulation (the sync core
|
||||
compiles under `wasm32-unknown-unknown`, validated by the POC). The
|
||||
8-byte format is simpler than the 9-byte (one fewer field to parse),
|
||||
strengthening the WASM-clean property.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- **5 extra bytes per TTY chunk.** 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`).
|
||||
For typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme
|
||||
multiplexing scenarios, the clean separation is worth the trade-off;
|
||||
for high-throughput bulk transfer, the escape hatch is
|
||||
multi-connection (one channels connection per leg), not stripping
|
||||
the header. This is the documented cost of the clean separation; the
|
||||
alternative (9-byte header with `stream_type` in the channels layer)
|
||||
carries a concept the channels layer doesn't own, which is the root
|
||||
cause this ADR addresses.
|
||||
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
|
||||
reads from an `AsyncRead` (the transport stream). Inside channels,
|
||||
it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the
|
||||
bytes the channels layer handed it after stripping its 8-byte
|
||||
header. The framing logic (stream_type constants, length validation,
|
||||
control message parsing) is unchanged. This is a bounded,
|
||||
well-scoped implementation change, not an architecture change. The
|
||||
same adaptation applies to any handler that parses its own framing
|
||||
from a payload buffer (call's `EventEnvelope` framing already reads
|
||||
from a buffer; tunnel and SSH don't parse the payload, so no
|
||||
adaptation). In alktty as built, `drive_session` already reads from
|
||||
a `BiStream` whose `AsyncRead` yields the post-channels-strip
|
||||
payload bytes — the same `wire.rs` code runs unchanged in both
|
||||
modes.
|
||||
- **`channel/open` loses the `stream_types` field.** alknet ADR-073's
|
||||
`channel/open` input included `stream_types: [u8]` (the active
|
||||
sub-stream set) and the response echoed the negotiated set. Under
|
||||
this ADR, the channels layer doesn't negotiate sub-stream sets —
|
||||
the handler owns its sub-stream multiplexing. The `stream_types`
|
||||
field is removed from `channel/open` (and from the
|
||||
`channel:stream_type_unavailable` error code). The `alpn` and
|
||||
`params` fields remain; the handler's sub-stream set is implicit in
|
||||
its ALPN's wire format. This is a small wire-format change to
|
||||
`channel/open` (one field removed); since the channels crate isn't
|
||||
implemented yet, there's no migration cost.
|
||||
- **`ChannelState.streams: HashMap<u8, ReassemblyBuffer>` becomes
|
||||
`ChannelState.reassembly: ReassemblyBuffer` (one per channel, not
|
||||
per `(channel_id, stream_type)`).** This is an internal
|
||||
simplification (fewer reassembly buffers, simpler drain logic) but
|
||||
is an implementation change, not an architecture one. The
|
||||
bounded-buffer backpressure (alknet ADR-076) is per-`channel_id`
|
||||
now, not per-`(channel_id, stream_type)` — the 1 MiB default and
|
||||
the 256-channel cap are unchanged; the per-channel memory ceiling is
|
||||
1 MiB (was up to 5 MiB for a TTY channel with 5 active
|
||||
stream_types). This is a net improvement (lower memory ceiling per
|
||||
channel), not a regression.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (wire format, accessor removal, two-mode reversal).** The
|
||||
8-byte chunk header layout (`channel_id:u32 + length:u32`), the
|
||||
removal of `stream_type` from the channels header, and the removal of
|
||||
`into_sub_streams()` are wire-format and API commitments. Changing
|
||||
them after the channels crate is implemented and handlers are written
|
||||
against them requires a version migration. Since the channels crate
|
||||
doesn't exist yet, the one-way door is being cast now, before
|
||||
implementation — the right time to cast a one-way door.
|
||||
|
||||
The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way
|
||||
in the same sense: once TTY's `wire.rs` runs in both modes (direct and
|
||||
inside-channels), re-introducing a separate inside-channels mode would
|
||||
be a rewrite of TTY's session driver. The trade is one unified session
|
||||
driver now vs. two-mode maintenance forever.
|
||||
|
||||
The add/strip API shape (alknet OQ-68) is a **two-way door** — whether
|
||||
the header add/strip is built into the read/write path or exposed as a
|
||||
standalone utility is an implementation detail that can change without
|
||||
breaking the wire format or the handler contract.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-071: channels wire format (amended — wire format is 8
|
||||
bytes, not 9; `stream_type` removed from the channels header; the
|
||||
stream_type decomposition is removed from the channels layer)
|
||||
- alknet ADR-074: ChannelBidiStreamSource (amended —
|
||||
`into_sub_streams()` removed; `accept_bi` is the only accessor,
|
||||
yields one `BiStream` per channel)
|
||||
- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed
|
||||
— TTY always uses its 5-byte format; the channels layer carries it
|
||||
transparently in the payload; the two-mode design is preserved but
|
||||
differs only in `BiStream` source, not in parsing)
|
||||
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
|
||||
layer this ADR builds on — `accept_bi` returns `BiStream`;
|
||||
`from_bidi` is the only public stream constructor)
|
||||
- alknet ADR-070: `BidiStreamSource` trait (the extension point
|
||||
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
|
||||
- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged —
|
||||
channel 0's chunks have `channel_id = 0` in the 8-byte header; the
|
||||
call protocol's framing is the payload)
|
||||
- alknet ADR-073: channel lifecycle operations (amended —
|
||||
`stream_types` field removed from `channel/open`;
|
||||
`channel:stream_type_unavailable` error code removed)
|
||||
- alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
|
||||
unchanged — demux reads 8-byte headers; one reassembly buffer per
|
||||
channel)
|
||||
- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged —
|
||||
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
|
||||
monotonic IDs)
|
||||
- alknet ADR-078: two-pump shutdown-on-completion (unchanged — the
|
||||
contract applies to `tokio::io::split(bidi)` halves)
|
||||
- alknet ADR-079: hub relay (unchanged in contract — 8-byte header,
|
||||
4-byte `channel_id` rewrite, payload byte-forwarded)
|
||||
- alknet ADR-080: `ChannelClient` (amended — `stream_types` field
|
||||
removed from `open_channel` and `Channel`)
|
||||
- alknet ADR-081: sub-crate decomposition (unchanged — 8-byte wire
|
||||
format in `channels-core`; call-protocol coupling in
|
||||
`channels-call`)
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
|
||||
(the 5-byte format carried transparently in the channels payload;
|
||||
the control channel split from Phase 7 is TTY-internal)
|
||||
- `src/channels.rs` — alktty's channels integration (the
|
||||
`register_openable` helper + `TtyOpenHandler` that runs
|
||||
`drive_session` on a channels-backed `BiStream`, the same code as
|
||||
the direct path)
|
||||
- Port origin: alknet ADR-093 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md`
|
||||
Reference in New Issue
Block a user