docs(research): rewrite stream-unification findings — channels as pure channel multiplexing

The previous framing ('mod 2 vs mod 3 vs mod 4 for the stream_type
space within a channel') was a symptom. The actual question is the
separation of concerns between the channels layer and the handler.

Resolution: the channels layer routes by channel_id only; handlers
own their sub-multiplexing on the BiStream they receive. Every
channel is a BiStream. The 'pass a stream to/from any ALPN' objective
becomes universal, not qualified.

The wire formats compose by construction: the 9-byte channels header
is the 5-byte TTY header with channel_id:u32 prepended. The channels
layer adds channel_id on write, strips it on read, hands the inner
5 bytes to the TTY handler. TTY's wire.rs works as-is. The
'double-chunking' objection (ADR-077's reason for rejecting
sub-multiplex inside channels) was about a 14-byte double-header; the
actual composition is 9 bytes total, shared across both layers because
the length prefix is shared.

This dissolves:
- The mod 2/3/4 question at the channels layer (the channels layer
  has no stream_type concept).
- The 'control isn't actually bidirectional' TTY flaw (TTY owns its
  sub-streams; stream_type 3 = ctrl_in, 4 = ctrl_out at the TTY layer).
- The 'into_sub_streams() as a second-class accessor' (removed;
  accept_bi is the only accessor, yields one BiStream per channel).
- The recursive composition question (made cleaner — strip a prefix
  at every level, uniform shape).
- The 'merge and split stderr' confusion (stderr is a handler concern;
  the channels layer carries bytes; TTY owns the stdout/stderr
  distinction).

ADR-077 is reversed: TTY always uses its 5-byte format, the channels
layer carries it transparently. The two-mode TTY design is preserved
but differs only in BiStream source, not in parsing.

No production constraint (develop branch is a rewrite, no one is
using this version yet). The decision is purely 'what's cleanest.'

One open sub-question: 8 bytes vs 9 bytes for the channels wire format.
9 bytes preserves TTY's wire.rs via literal strip/add; 8 bytes is more
uniform across inner layers but requires rewriting TTY's format.
Default assumption: 9 bytes (the strip/add property is the elegant one).

ADR-093 is ready to draft. The structural question is resolved.
This commit is contained in:
glm-5.2 committed 2026-07-18 07:32:53 +00:00
1 parent b5397f61aa
commit 073bbba06a
1 file changed
+323 -277
+323 -277
View File
@@ -3,298 +3,266 @@ status: draft
last_updated: 2026-07-18
---
# stream-unification — Findings: the stream_type convention and the TTY/channels convergence
# stream-unification — Findings: channels as pure channel multiplexing
**Status:** Draft findings, iterating. Per the research-then-sync
pattern, this doc iterates in `docs/research/`; we fix inter-document
drift here, then sync to `docs/architecture/` and the ADRs only after
it settles.
**Scope:** The multiplexing layer — the `stream_type` space within a
channel. This is *above* the transport leaf (ADR-092, settled) and
*below* the channel-protocol layer (ADR-072/073). The transport leaf
(`BiStream` as the handler-facing duplex type) is settled in ADR-092
and is not re-litigated here.
**Scope:** The multiplexing layer — how channels, TTY, and other
handlers compose their framing. This is *above* the transport leaf
(ADR-092, settled) and *below* the channel-protocol layer (ADR-072/
073, settled). The transport leaf (`BiStream` as the handler-facing
duplex type) is settled in ADR-092 and is not re-litigated here.
**Date:** 2026-07-18
---
## TL;DR
The previous framing — "mod 2 vs mod 3 vs mod 4 for the `stream_type`
space within a channel" — was a symptom. The actual question is the
separation of concerns between the channels layer and the handler,
and the resolution is **layered framing via strip/add**: the channels
layer routes by `channel_id` only; handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them. Every channel is a
`BiStream`. The "pass a stream to/from any ALPN" objective becomes
universal, not qualified.
The channels wire format composes with TTY's wire format by
construction: the 9-byte channels header is the 5-byte TTY header with
`channel_id:u32` prepended (`[channel_id][stream_type][length][payload]`
= `[channel_id]` + TTY's `[stream_type][length][payload]`). The
channels layer adds `channel_id` on write, strips it on read, hands
the inner 5 bytes to the TTY handler. TTY's `wire.rs` works as-is.
The "double-chunking" objection (ADR-077's reason for rejecting
sub-multiplex inside channels) was about a 14-byte double-header; the
actual composition is 9 bytes total, shared across both layers.
This dissolves the mod 2/3/4 question at the channels layer (the
channels layer has no `stream_type` concept), fixes the "control
isn't actually bidirectional" TTY flaw at the TTY layer (TTY owns
its sub-streams), and makes recursive composition literal (each
layer strips its header, the inner layer adds its own).
**No production/backward-compat constraint.** The develop branch is a
rewrite; no one is using this version yet. If TTY's `wire.rs` needs
rework, it needs rework — no time constraint, no duct tape. If it
doesn't need rework, no need to spend the resource. The decision is
purely "what's cleanest," not "what's least disruptive."
---
## Layering (to keep the questions separate)
| Layer | Question | Status |
|-------|----------|--------|
| Transport leaf | What type does `accept_bi` return? How does a handler get a duplex byte stream? | **Settled — ADR-092** (drafted, pushed). `accept_bi` returns `BiStream`; `from_bidi` is the only public stream constructor; the split never crosses a crate boundary as part of a constructor. |
| Multiplexing (this doc) | How are `stream_type` values assigned within a channel? What's the addressing convention? | **In progress.** ADR-071's mod-3 group framing vs the proposed mod-2/mod-4 instance framing. |
| Multiplexing (this doc) | How do channels, TTY, and other handlers compose their framing? Who owns sub-stream multiplexing? | **In progress — this resolution.** Channels layer routes by `channel_id`; handlers own their sub-multiplexing on the `BiStream` they receive. |
| Channel protocol | How are channels opened/closed? What's channel 0? | **Settled — ADR-072/073.** Channel 0 = `alknet/call` (hardcoded); channels 1..N opened via `channel/open`. Not re-litigated here. |
The two findings-doc questions that got conflated in the previous
draft, separated:
---
- **Transport-leaf split/recombine** ("can `tokio::io::join`/`split`
recombine halves from different sources?") — ADR-092's layer.
Answer: yes, stdlib `tokio::io::join`/`split` is a pure type
combinator; the halves don't need to come from the same source.
Settled; not this doc's concern.
- **Stream_type split/recombine** ("if stderr is its own instance
with an unused write half, does the unused half cause problems?")
— this doc's layer. Answer (verified in the existing POC code,
`alknet-channels-poc/src/demux.rs:91-109, 161-181` and
`mux.rs:58-63, 152-177`): the demux/mux is per-`stream_type`
independent — there is **no pairing assumption** in the mechanism.
An unused `stream_type` is an idle mpsc channel, not a wart. The
mod-2 framing is trivially clean. **No POC needed** — the
load-bearing property is already proven by the 28-test POC.
## The structural question (the actual tangle)
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 destructs 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.
ADR-074's current design resolves this with two access paths
(`accept_bi` for clean-pair channels, `into_sub_streams` for
multi-stream channels), making the "pass a stream to/from any ALPN"
objective *qualified* — it applies to single-stream channels, not
multi-stream channels. The mod 2/mod 3/mod 4 question was a numbering
symptom of this 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. No `into_sub_streams()`, no second-class
accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (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. `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. The channels
layer doesn't carry control. The "control isn't actually
bidirectional" flaw is fixed at the TTY layer (stream_type 3 =
ctrl_in, 4 = ctrl_out), not the channels layer.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`.
The outer layer strips its `channel_id`; the inner layer adds its
own. Each level is the same shape — `BiStream → accept_bi → N
BiStreams`.
---
## The core question: stream_type assignment convention
## How the wire formats compose (the add/strip insight)
The mechanism (per-`stream_type` independent demux/mux, validated by
the channels POC) routes by `(channel_id, stream_type)` regardless of
convention. The convention choice — *how stream_type values are
assigned to roles* — is a documentation/ergonomics choice, not a
mechanism choice. Both conventions work; the question is which is
cleaner.
The channels wire format and TTY's wire format compose by construction:
### ADR-071's current convention: mod-3 groups
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write | in (stdin) |
| | 1 | read | out (stdout) |
| | 2 | read | err (stderr) |
| Control | 3 | write | ctrl_in |
| | 4 | read | ctrl_out |
| | 5 | read | ctrl_err (optional) |
| Future | 6/7/8, 9/10/11, ... | write/read/read | next groups |
Formula: `stream_type % 3 == 0` → write, `% 3 == 1` → read,
`% 3 == 2` → diagnostic read (err).
**The flaw: stderr is structural.** The `+2 = err` slot bakes a
*unidirectional* role (stderr is server→client only) into a
*bidirectional* group structure (in/out/err). Every group either
allocates an err slot it may not use (tunnel has no err) or skips the
group structure. The group concept (data vs control) is real, but the
3-slot shape is asymmetric — bidirectional things are two halves, but
the group has three slots, one of which is a unidirectional leaf.
### Proposed convention: mod-2/mod-4 by instance
An **instance** is a self-contained bidirectional unit, addressed as
a contiguous block of `stream_type` values within a channel.
**No control channel** — mod 2:
| Instance | in (write) | out (read) |
|----------|-----------|-----------|
| 0 | 0 | 1 |
| 1 | 2 | 3 |
| 2 | 4 | 5 |
| ... | | |
| 127 | 254 | 255 |
128 instances per channel. Instance K uses `stream_type = 2K` (in)
and `2K+1` (out).
**With control channel** — mod 4:
| Instance | in (write) | out (read) | ctrl_in (write) | ctrl_out (read) |
|----------|-----------|-----------|-----------------|-----------------|
| 0 | 0 | 1 | 2 | 3 |
| 1 | 4 | 5 | 6 | 7 |
| 2 | 8 | 9 | 10 | 11 |
| ... | | | | |
| 63 | 252 | 253 | 254 | 255 |
64 instances per channel. Instance K uses `4K` (in), `4K+1` (out),
`4K+2` (ctrl_in), `4K+3` (ctrl_out).
**The `stream_type` space is the instance address space, not a role
space.** A handler talks about "instance K of my channel" as a
contiguous block of stream_types. This is richer than ADR-071's
"data group / control group" framing — the instance is the addressing
unit, and a handler can have N independent bidirectional sub-streams
on one channel, each with or without its own control.
**Combined address space:** `channel_id × instance`. Channel 0 is
hardcoded `alknet/call` (ADR-072), so channels 1..255 are dynamic —
~255 × 128 (no control) or ~255 × 64 (with control) logical
sub-streams per channels connection. Huge; the load-bearing count
for the recursive-multiplexing property below.
### Why mod 2 wins
1. **Uniformity.** Every bidirectional thing is exactly two
stream_types (write, read). Control is a pair; io is a pair;
future sub-streams are pairs. No "err is a third half" asymmetry.
2. **The "unused write half" wart is trivially clean** (verified in
the existing POC code — see "Stream_type split/recombine" above).
An unused `stream_type` is an idle mpsc channel: no dangling
sender, no premature EOF (EOF fires on sender drop or zero-length
sentinel), no flow-control weirdness (independent bounded
buffers). The lenient unknown-`stream_type` path
(`demux.rs:161-174`) means even a stray write to an unallocated
`stream_type` is dropped with a counter bump, not a panic.
3. **Stderr is just another instance.** PTY mode (stderr merged into
stdout server-side) declares instance 0 only. Pipe mode (separate
stderr) declares instance 0 (io) + instance 1 (stderr-as-bidirectional,
write half unused). The unused write half costs one idle mpsc
channel; cheap. No structural asymmetry.
4. **The demux/mux doesn't care.** The convention is documentation;
the mechanism routes by `(channel_id, stream_type)` regardless.
Both conventions work; mod 2 is the cleaner documentation.
**The mod-2-vs-mod-3 question is settled by the existing POC
evidence.** No new POC needed. The mechanism supports both; mod 2
wins on uniformity, and the "unused write half" property that made
mod 2 look like a wart is trivially clean in the actual code.
---
## The TTY control channel flaw (separate, already specified)
`crates/alknet-tty/src/wire.rs:13,32-33`:
```
STREAM_CONTROL: u8 = 3 // "bidirectional, JSON control message"
InvalidStreamType > 3
channels: [channel_id:u32 BE][stream_type:u8][length:u32 BE][payload]
= [channel_id] + TTY's [stream_type][length][payload]
4 bytes 5 bytes
```
One `stream_type` both sides write to — the "control isn't actually
bidirectional" flaw. ADR-071 §stream_type decomposition already fixes
this at the wire-format level (stream_types 3 = ctrl_in, 4 = ctrl_out);
ADR-077 amends ADR-052 to add stream_type 4. The TTY code hasn't been
updated — it still has the old "bidirectional 3" comment and the `> 3`
bound.
The 9-byte channels header is the 5-byte TTY header with `channel_id`
prepended. The channels layer adds `channel_id` on write, strips it
on read, hands the inner 5 bytes to the TTY handler. TTY's `wire.rs`
parses those 5 bytes exactly as it does today. No double-chunking;
the 9 bytes serve both layers because the `length` prefix is shared
(both layers use it to frame the payload).
**This is an implementation-lag, not a design question.** The fix is
specified; the code needs to catch up. The mod-2/mod-4 instance
framing subsumes this fix: control becomes instance 0's ctrl_in/ctrl_out
pair (types 2/3 under mod 4), not a standalone "bidirectional" stream_type.
### The add/strip utility
Each layer has its own add/strip pair:
- **Channels layer**: `add_channel_id(channel_id, inner_chunk) -> chunk`
on write; `strip_channel_id(chunk) -> (channel_id, inner_chunk)` on
read.
- **TTY layer** (inside the handler): parses the inner 5-byte chunk
per its existing `wire.rs`. 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 `channel_id`, the inner layer adding its own — same code, same
shape, each level.
### What this means for ADR-077's rejection
ADR-077 rejected sub-multiplex inside channels on the grounds of
"double-chunking (5-byte inside 9-byte)." The actual composition is
not 14 bytes — it's 9 bytes, shared. The 5-byte TTY format is the
*inner* format; the 9-byte channels format is the *outer* format; the
outer format's `stream_type`/`length` fields ARE the inner format's
header. There is no double-header, only a prefix.
ADR-077's rejection was based on a misunderstanding of how the layers
compose. The add/strip composition makes the 9-byte channels header
carry TTY's 5-byte header transparently — no waste, no double-chunk.
The rejection is reversed by this resolution.
---
## TTY/channels convergence
## The "merge and split" trap (what was conflated in earlier rounds)
Channels was written after TTY as a natural extension: TTY's 5-byte
header + `channel_id:u32` prefix = the 9-byte channels header. The
convergence has two parts.
Earlier rounds of this discussion got confused by "can we merge and
split stderr." The vocabulary is now clear:
### Semantic convergence (independent of format)
- **Untagged interleave** (`StreamExt::merge(stdout, stderr)`) — bytes
arrive interleaved, no way to tell which came from where. The
distinction is lost. This is "true merge" — what PTY mode does, what
Docker `Tty: true` does. Once merged this way, stderr is
unrecoverable. No amount of "interleaved reading" recovers it
without a tag.
- **Tagged interleave** — each chunk carries a tag (stream_type), so
the receiver demuxes. This is what the channels layer already is
(for `channel_id`) and what TTY's sub-multiplex is (for `stream_type`).
The tag IS the framing.
Regardless of format choice, TTY and channels present the same shape
to handlers: a set of unidirectional sub-streams declared per-channel
at `channel/open` time, paired into bidirectional instances where the
handler wants a joined `BiStream`. `into_sub_streams()` (ADR-074)
returns the declared stream_types as `Vec<(u8, SubStreamHandle)>` with
`SubStreamHandle::Send` (write half) or `Recv` (read half). The
handler joins the pairs it wants via `tokio::io::join`. The channels
layer exposes the leaves; the handler composes them.
ADR-074's two paths (`accept_bi` for the joined 0/1 pair,
`into_sub_streams` for the typed leaves) become the same data,
presented differently. `accept_bi` is a convenience that joins 0/1 for
the common case (tunnel, SSH, echo, HTTP); `into_sub_streams` is the
primary accessor (the unidirectional leaves).
### Format convergence (the open question)
**Option A (current, ADR-077): two formats coexist.** TTY-direct keeps
the 5-byte format; TTY-inside-channels uses 9-byte. The 4-byte
`channel_id` overhead is paid only inside channels.
**Option B (retire the 5-byte format): TTY-direct uses 9-byte.** One
demux implementation, one set of stream_type semantics, one crate.
Sub-options for the `channel_id`:
- **B1:** TTY-direct is a channels connection with `channel_id = 0` =
`alknet/tty` (breaks ADR-072's "channel 0 is call" rule).
- **B2:** TTY-direct is a channels connection with `channel_id = 0` =
`alknet/call` (unused, no `channel/open` issued) + `channel_id = 1` =
`alknet/tty` (pre-allocated). Pays one unused channel for rule
consistency. Channel 0 is *always* call, even when unused.
**Option B is the bigger call.** It's a wire-format change with
backward-compat implications. It's the one open question that benefits
from a POC — see below.
"Interleaved reading" doesn't avoid stream_type; it IS stream_type.
The channels layer is tagged interleave by construction. The design
question is not "how to avoid stream_type" but "which layer owns
which tag" — and the resolution is "channels layer owns `channel_id`,
handler owns `stream_type`."
---
## The recursive multiplexing property
## Stderr under this resolution
A channels connection with N channels, each with up to 128 (or 64)
instances, has N×128 (or N×64) logical sub-streams. An instance can
itself be a channels connection (recursive composition, ADR-074 — a
channel with ALPN `alknet/channels` inside `alknet/channels`). The
"channel within a channel within a channel" shape is unbounded, and
the mod-2/mod-4 instance framing makes each level uniform — every
level is "pairs of stream_types (or 4-tuples with control), declared
per-channel, addressed by instance."
Stderr is a handler concern, not a channels concern. Two cases:
This is a property, not a feature. The primary use case is one level
of multiplexing. Recorded here because the instance framing makes it
cleaner than ADR-071's group framing did — the instance is the
recursive unit; the group was not.
- **PTY mode** (Docker `Tty: true`): the PTY merges stdout and stderr
into one output stream. The TTY handler sees one output stream,
sub-demuxes nothing for stderr (there is no stderr). One channel,
one `BiStream`, no stderr stream_type. Mod 2 at the TTY sub-stream
level (one pair: in/out). Clean.
- **Pipe mode** (Docker `Tty: false`): the OS gives the handler two
distinct streams (child.stdout, child.stderr). The TTY handler
sub-demuxes these onto its 5-byte format's stream_types (0 = in,
1 = out, 2 = err). The channels layer carries the resulting chunks
transparently. The "stderr as a unidirectional read" asymmetry is
inside TTY's sub-stream space, not the channels layer's.
The channels layer never sees stderr. It sees bytes. The TTY handler
owns the stdout/stderr distinction entirely.
---
## POC candidates
## The one open question: 8 bytes vs 9 bytes
### POC 1: stderr split/recombine — **already answered, no POC needed**
The channels wire format can be either:
**Original framing:** "If stderr is its own instance (mod 2, write
half unused), does the unused write half cause problems — dangling
sender, premature EOF, flow-control weirdness?"
- **9 bytes** (`[channel_id:u32][stream_type:u8][length:u32][payload]`):
preserves TTY's 5-byte format exactly (strip 4 bytes → TTY's native
format). The `stream_type` byte is the inner layer's framing,
carried transparently by the channels layer. Non-TTY inner layers
(tunnel, call) carry a `stream_type` byte they don't use (set to 0,
ignored by the inner layer, or simply not parsed). 1 byte of
overhead per chunk for non-TTY inner layers.
- **8 bytes** (`[channel_id:u32][length:u32][payload]`): the channels
layer carries only `channel_id` + `length`. The inner layer's
framing is entirely within the payload. No unused byte for non-TTY
inner layers. But TTY's `wire.rs` doesn't compose by stripping —
TTY's 5-byte format would need rewriting to fit inside the 8-byte
payload (the length prefix position changes).
**Answer (verified in existing POC code):** No. The demux/mux is
per-`stream_type` independent — there is no pairing assumption in the
mechanism. An unused `stream_type` is an idle mpsc channel. The
lenient unknown-`stream_type` path drops stray writes with a counter
bump. The 28-test channels POC already validated per-`stream_type`
independence (`demux_three_concurrent_channels_no_cross_contamination`,
`demux_unknown_channel_drops_lenient`, `recv_dropped_sender_is_eof`).
**The 9-byte composition is elegant because the strip is literal** —
the channels layer reads 9 bytes, hands the inner 5 to TTY, TTY parses
them as its native format. TTY's `wire.rs` works unchanged.
**Status:** Confirmatory, not exploratory. The load-bearing property
is already proven. The mod-2 framing is trivially clean. No new POC.
**The 8-byte composition is more uniform across inner layers** — the
channels layer doesn't carry a `stream_type` byte it doesn't know
about. But TTY's format needs rewriting, and the layered composition
is no longer "strip a prefix" but "parse the inner framing from the
payload."
### POC 2: TTY-direct-as-channels — the one open question
**The decision is "what's cleanest," not "what's least disruptive"**
(no production constraint, no backward-compat). Considerations:
**Question:** Is Option B1 (TTY-direct = channels with `channel_id = 0`
= `alknet/tty`, breaking ADR-072) or Option B2 (channel 0 = call
unused, channel 1 = TTY) feasible and clean? Or is the 4-byte
`channel_id` overhead per chunk in TTY-direct mode a real concern
for terminal I/O? And is backward-compat with existing TTY-direct
deployments a real constraint, or are there no existing deployments
to break?
- 9 bytes: preserves TTY's `wire.rs` as-is. The strip/add is literal.
The cost is 1 byte per chunk for non-TTY inner layers (tunnel,
call), where the `stream_type` byte is unused.
- 8 bytes: the channels layer is uniform — it carries only
`channel_id` + `length`, nothing else. The cost is TTY's `wire.rs`
needs rewriting (the length prefix position changes; TTY's 5-byte
format doesn't compose by stripping).
**Why it matters:** If Option B is clean and backward-compat is
non-binding, the 5-byte format retires, the demux implementation
unifies, and the TTY crate's direct mode becomes a thin wrapper. If
backward-compat is binding or the overhead is a concern, Option A
(two formats coexist per ADR-077) stands.
**Scope:** ~200 lines. A POC that runs the TTY adapter over a
channels-format demux with `channel_id = 0` (B1) and `channel_id = 1`
(B2), using the existing TTY `TtyBackend` (the local pipe backend).
Verify the `pty.rs` / `pipe.rs` tests still pass in shape. Measure
chunk overhead if relevant.
**Output:** A `docs/research/alknet-channels/poc-tty-direct-as-channels.md`
summary. Recommends Option A, B1, or B2. If B, becomes the input to
ADR-094 (retire the 5-byte format). If A, ADR-077 stands and the
5-byte format is kept.
### POC 3: recursive composition — low leverage, deferred
**Question:** Does `alknet/channels` inside `alknet/channels` work
end-to-end? The abstraction permits it (ADR-074); the POC didn't
validate it (`poc-summary.md` §"What the POC Does NOT Validate" #5).
**Why it matters:** Low leverage — the instance framing makes it
cleaner, but the primary use case is one level. Recursive composition
is a property, not a feature.
**Status:** Deferred. Not a POC for this round.
My read: 9 bytes. The 1-byte overhead for non-TTY is noise (chunks
are not tiny for tunnel/call). The literal strip/add is the elegant
property that makes the layered composition uniform — every layer's
header is a prefix, strip the prefix → inner layer's format. The 8-
byte option breaks the strip-prefix property and forces TTY to
re-derive its framing from the payload. But this is your call —
you know the use cases and the chunk-size tradeoffs.
---
@@ -303,24 +271,97 @@ is a property, not a feature.
| ADR | Scope | Status |
|-----|-------|--------|
| **ADR-092** | Transport leaf: `BiStream` as the handler leaf; `accept_bi` returns `BiStream`; `from_stream` removed; `from_bidi` is the only public stream constructor. | **Drafted, pushed** (`f8d4650`, `528cfa0`). Load-bearing, separable. |
| **ADR-093** | Multiplexing: mod-2/mod-4 instance framing; per-channel stream_type declaration; `into_sub_streams()` as the primary accessor; demux convention-agnostic. Amends ADR-071 (stream_type decomposition), ADR-074 (`into_sub_streams`), ADR-077 (TTY uses mod 2; control channel fixed). | **Ready to draft.** The mod-2-vs-mod-3 question is settled by existing POC evidence; the control channel fix is already specified in ADR-077; the instance framing is the cleaner documentation. |
| **ADR-094** | Format convergence: retire the 5-byte TTY-direct format in favor of 9-byte channels format. Option A (keep 5-byte), B1 (channel 0 = TTY), or B2 (channel 0 = call unused, channel 1 = TTY). Backward-compat analysis. | **Pre-ADR**, drafting after POC 2 recommends an Option. |
| **ADR-093** | Channels layer as pure channel multiplexing: routes by `channel_id` only; handlers own sub-multiplexing on the `BiStream` they receive; `into_sub_streams()` removed; every channel is a `BiStream`. The add/strip composition. Amends ADR-071 (channels layer has no `stream_type` concept), ADR-074 (`into_sub_streams` removed, `accept_bi` is the only accessor), ADR-077 (reversed — TTY always uses its 5-byte format, the channels layer carries it transparently). | **Ready to draft.** The structural question is resolved; the one open sub-question is 8 vs 9 bytes (below). |
| **ADR-094** | (Optional) The 8-vs-9-byte decision, if it warrants a separate ADR. Or folded into ADR-093 if the decision is straightforward. | **Pre-ADR**, drafting with or after ADR-093. |
---
## What changes in each crate
### `alknet-core` (ADR-092's changes, plus this resolution's implications)
- ADR-092's changes: `accept_bi` returns `BiStream`; `from_stream`
removed; `from_bidi` is the only public stream constructor;
`SendStream`/`RecvStream` collapse to thin newtypes.
- This resolution doesn't change core beyond ADR-092. `BiStream` is
the handler leaf; the channels layer yields `BiStream`s; handlers
parse them per their ALPN. Core is not aware of the layered framing.
### `alknet-channels` (the bulk of this resolution)
- The channels layer routes by `channel_id` only. `stream_type` is
the inner layer's framing, carried transparently. The channels
layer has no `stream_type` concept.
- `into_sub_streams()` is removed. `accept_bi` is the only accessor;
it yields one `BiStream` per channel.
- The wire format is `[channel_id][...inner layer's framing...]
[payload]`. The inner layer's framing is whatever the handler
expects (TTY's 5-byte, call's length-prefix, tunnel's raw bytes).
- The add/strip utility: `add_channel_id` on write,
`strip_channel_id` on read.
- Recursive composition is literal: an `alknet/channels` channel
runs another channels demux on its `BiStream`. The outer layer
strips its `channel_id`; the inner layer adds its own.
### `alknet-tty`
- TTY always uses its 5-byte format — direct mode and inside-channels
mode. The `channels` feature on `alknet-tty` becomes "run TTY's
sub-demux on a channels-backed `BiStream`" — the same code as
direct mode, different `BiStream` source.
- The control channel is sub-demuxed by TTY, not the channels layer.
The "control isn't actually bidirectional" flaw is fixed at the TTY
layer: stream_type 3 = ctrl_in (write), 4 = ctrl_out (read). TTY's
`wire.rs` needs updating (the `STREAM_CONTROL = 3` "bidirectional"
comment and the `InvalidStreamType > 3` bound are the implementation
lag ADR-077 already specified).
- The 5-byte format is the inner format. The channels layer (when
TTY is inside channels) strips its `channel_id` and hands TTY the
inner 5 bytes. TTY's `wire.rs` parses them as its native format.
- ADR-077 is reversed: the 5-byte format is NOT scoped to direct —
it's TTY's internal format, carried transparently by the channels
layer. The two-mode TTY design (direct vs inside-channels) is
preserved, but the modes differ only in *where the `BiStream` comes
from*, not in *how TTY parses it*. The same `wire.rs` code runs in
both modes.
### `alknet-call`, `alknet-ssh`, etc.
- The call protocol's `EventEnvelope` framing is the inner layer's
format. The channels layer carries it transparently. No change to
the call protocol itself.
- SSH runs its own channel protocol on the `BiStream` the channels
layer gives it. No change to SSH.
- Tunnel uses the `BiStream` as raw bytes. No sub-multiplexing.
---
## The recursive multiplexing property (made cleaner)
A channels connection carries N channels, each a `BiStream`. A
channel with ALPN `alknet/channels` runs another channels demux on
its `BiStream` — the outer layer strips its `channel_id`, the inner
layer adds its own. Each level is the same shape: `BiStream →
accept_bi → N BiStreams`. The recursion is unbounded and uniform at
every level.
This is a property, not a feature. The primary use case is one level
of multiplexing. But the add/strip composition makes it cleaner than
ADR-071's group framing did — the recursion is the same operation
(strip a prefix) at every level, not a different framing per level.
---
## Open questions
- **5-byte format retirement.** POC 2 resolves. Default assumption:
Option A (two formats coexist, ADR-077 stands) is safer; Option B
(retire 5-byte) is cleaner but a wire-format change. POC validates
feasibility and overhead.
- **`channel 0 is always call` (ADR-072) under TTY-direct-as-channels.**
If POC 2 recommends B1 (TTY-direct channel 0 = `alknet/tty`),
ADR-072 needs an exception or amendment. If B2 (channel 0 = call
unused, channel 1 = TTY), ADR-072 stands. If A (keep 5-byte), no
change.
- **Recursive composition.** Deferred. The instance framing makes it
cleaner, but it's not a goal. POC 3 is low leverage.
- **8 bytes vs 9 bytes.** The one open sub-question. 9 bytes
preserves TTY's `wire.rs` via literal strip/add; 8 bytes is more
uniform across inner layers but requires rewriting TTY's format.
Default assumption: 9 bytes (the strip/add property is the elegant
one). Decision is "what's cleanest" — no production constraint.
- **Recursive composition.** Deferred. The add/strip composition
makes it cleaner, but it's not a goal. Low-leverage POC if ever
needed.
---
@@ -328,26 +369,31 @@ is a property, not a feature.
- ADR-092: `BiStream` as the handler leaf (the transport-leaf layer,
settled; this doc is the layer above it)
- ADR-071: channels wire format (the mod-3 stream_type decomposition
ADR-093 amends)
- ADR-074: `ChannelBidiStreamSource` / `into_sub_streams` (the
per-channel sub-stream accessor ADR-093 amends)
- ADR-077: TTY inside channels (the two-mode TTY design ADR-093
amends; the 5-byte format scoping ADR-094 may retire)
- ADR-071: channels wire format (amended by this resolution — the
channels layer routes by `channel_id` only; `stream_type` is the
inner layer's framing, carried transparently)
- ADR-074: `ChannelBidiStreamSource` / `into_sub_streams` (amended —
`into_sub_streams` removed; `accept_bi` is the only accessor, yields
one `BiStream` per channel)
- ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte
format; the channels layer carries it transparently; the two-mode
design is preserved but differs only in `BiStream` source, not in
parsing)
- ADR-072: channel 0 pre-negotiated as `alknet/call` (the hardcoded
channel_id constraint)
- ADR-073: channel lifecycle operations (the `stream_types` field
declared at `channel/open` time — the per-channel declaration that
makes the demux convention-agnostic)
- `docs/research/alknet-channels/poc-summary.md` — the channels POC
(28 tests) that validated per-`stream_type` independence
- `/workspace/alknet-channels-poc/src/demux.rs:91-109, 161-181` —
the demux's per-`stream_type` routing (no pairing assumption)
- `/workspace/alknet-channels-poc/src/mux.rs:58-63, 152-177` — the
mux's per-`(channel_id, stream_type)` independent pumps
- `crates/alknet-tty/src/wire.rs:13,32-33` — the `STREAM_CONTROL = 3`
"bidirectional" flaw (implementation lag; fix specified in ADR-077,
subsumed by the mod-4 instance framing)
`channel_id` constraint)
- ADR-073: channel lifecycle operations (the `channel/open` operation
that allocates `channel_id`s)
- `crates/alknet-tty/src/wire.rs:13,32-33,150-151` — the `STREAM_CONTROL
= 3` "bidirectional" flaw and the `InvalidStreamType > 3` bound
(implementation lag; fix specified in ADR-077, subsumed by this
resolution's "TTY owns its sub-streams")
- `/workspace/alknet-channels-poc/src/demux.rs:91-109, 161-181` — the
demux's per-`stream_type` routing (no pairing assumption; the
mechanism supports any convention; this resolution says the channels
layer doesn't have a convention, the handler does)
- `crates/alknet-tty-local/tests/pipe.rs:363 separate_stderr` — the
test that proves separate stderr is a real feature, not vestigial
(resolves under mod 2 as instance 1 with unused write half)
test that proves separate stderr is a real feature (resolves as a
TTY-layer concern, not a channels-layer concern)
- `docs/research/alknet-channels/poc-summary.md` — the channels POC
(28 tests) that validated the per-`channel_id`/`stream_type`
routing mechanism the channels layer uses