docs(research): rewrite stream-unification findings — focus on the multiplexing layer

The previous draft was mixing two layers (transport leaf and stream_type
multiplexing) and including side-topics (WsBidiStream home, etc.) that
weren't load-bearing, which confused agents into conflating tokio::io::
join/split (ADR-092's layer, settled) with the demux/mux stream_type
layer (this doc's layer, in progress).

Rewrite to be focused:

- Layering section upfront separates transport leaf (ADR-092, settled),
  multiplexing (this doc), and channel protocol (ADR-072/073, settled).
  The two questions that got conflated are explicitly separated.
- Drop the ADR-092 recap (it's in the ADR, not this doc's concern).
- Drop the 'five abstractions' table (ADR-092's framing, not this doc's).
- Drop the WS open question (irrelevant to multiplexing).
- Record that POC 1 (stderr split/recombine) is already answered by the
  existing POC evidence: per-stream_type independent demux/mux (verified
  in demux.rs:91-109, 161-181 and mux.rs:58-63, 152-177) means the
  'unused write half' is an idle mpsc channel, not a wart. The mod-2
  framing is trivially clean. No new POC needed.
- The mod-2-vs-mod-3 question is settled by existing evidence; ADR-093
  is ready to draft.
- The one open question that benefits from a POC is POC 2
  (TTY-direct-as-channels, for the format-convergence / retire-5-byte
  call). POC 3 (recursive composition) is low leverage, deferred.
- The TTY control channel flaw is flagged as implementation-lag, not a
  design question (fix specified in ADR-077, subsumed by mod-4 instance
  framing).

This drops the scope to what the doc is actually about: the stream_type
convention and the TTY/channels convergence.
This commit is contained in:
glm-5.2 committed 2026-07-18 05:51:54 +00:00
1 parent 909935ded3
commit b5397f61aa
1 file changed
+191 -338
+191 -338
View File
@@ -3,144 +3,61 @@ status: draft
last_updated: 2026-07-18
---
# stream-unification — Findings: the leaf, the instance, the convergence
# stream-unification — Findings: the stream_type convention and the TTY/channels convergence
**Status:** Draft findings, iterating. This doc is the working scratch
for the stream/multiplexing redesign that surfaced during the
`alknet-crate-extraction` Phase 6 deep dive. It is **not** synced to the
architecture specs — per the research-then-sync pattern, we iterate
here, fix inter-document drift, and only then sync to
`docs/architecture/` and the ADRs. ADR-092 (`BiStream` as the handler
leaf) has been drafted and pushed because it's load-bearing and
separable; everything else in this doc is pre-ADR and will produce
ADR-093 (the multiplexing/stream_type redesign) and possibly ADR-094
(retire the 5-byte TTY-direct format) once it settles.
**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.
**Date:** 2026-07-18
**Scope:** (a) the transport-leaf unification (ADR-092, drafted); (b)
the stream_type / multiplexing redesign that ADR-092 enables; (c) the
TTY/channels convergence; (d) the POC candidates that would take
guesswork out before committing.
---
## TL;DR
## Layering (to keep the questions separate)
The deep dive surfaced a layered tangle:
| 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. |
| 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. |
1. **Transport leaf is split.** `accept_bi` returns `(SendStream,
RecvStream)`; every handler that wants a duplex stream re-joins
(HTTP's `QuicStream`, 44 lines) or bypasses (WS's `WsStream`).
Five abstractions exist for "a bidirectional byte stream."
**Resolved by ADR-092** (drafted, pushed): `accept_bi` returns one
`BiStream`; the join moves into core's quinn/iroh impls once; the
split never crosses a crate boundary as part of a constructor
(`Connection::from_stream` removed, `from_bidi` is the only public
stream constructor).
The two findings-doc questions that got conflated in the previous
draft, separated:
2. **The control channel "isn't actually bidirectional"** in the
current TTY code (`wire.rs:13,32-33`: `STREAM_CONTROL = 3` is one
stream_type both sides write to). ADR-071's mod-3 stream_type
decomposition already fixes this at the wire-format level
(stream_types 3 = ctrl_in, 4 = ctrl_out), but the TTY code hasn't
been updated. ADR-077 amends ADR-052 to add stream_type 4, but the
implementation lags.
3. **ADR-071's mod-3 stream_type decomposition is structural but
asymmetric.** "Data group 0/1/2 = in/out/err; control group 3/4/5 =
ctrl_in/ctrl_out/ctrl_err" bakes stderr into the group structure.
The cleaner framing is **mod 2 by instance**: an *instance* is a
self-contained bidirectional unit, addressed as a contiguous block
of stream_types. No control: instance K uses `2K`/`2K+1` (128
instances/channel). With control: instance K uses
`4K`/`4K+1`/`4K+2`/`4K+3` (64 instances/channel). The stream_type
space is the *instance address space*, not a *role* space. This is
richer than "control is just another pair" — the instance is the
addressing unit.
4. **TTY and channels should converge on one format.** Channels was
written after TTY and was a natural extension — TTY's 5-byte header
+ a `channel_id:u32` prefix = the 9-byte channels header. TTY
should be rewritten to use the channels format. The one hardcoded
`channel_id` is channel 0 = `alknet/call` (ADR-072); everything
else is dynamic. Whether TTY-direct retires the 5-byte format
entirely (using 9-byte with `channel_id = 0` or `channel_id = 1`)
is a bigger call — backward compat — flagged as a POC candidate
and a possible ADR-094.
5. **The recursive multiplexing property follows.** A channels
connection with N channels, each with up to 128 (or 64) instances,
has N×128 (or N×64) logical sub-streams. Combined address space
(channel_id × instance) is ~255×128 or ~255×64. An instance can
itself be a channels connection (recursive composition, ADR-074).
The "channel within a channel within a channel" shape is unbounded,
and the mod-2/mod-4 instance framing makes each level uniform.
**POC candidates** (ordered by leverage): stderr split/recombine
(load-bearing for the mod-2 vs mod-3 decision); TTY-direct-as-channels
(load-bearing for the format-convergence decision); recursive
composition (low leverage — already implied by the abstraction, just
needs validation).
- **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 tangle, restated
## The core question: stream_type assignment convention
Five abstractions for "a bidirectional byte stream" before ADR-092:
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.
| # | Abstraction | Where | Status |
|---|-------------|-------|--------|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | Vestigial in code — zero consumers. Resurrected by ADR-092 as a concrete newtype. |
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | Handler-facing. Leaf split. ADR-092 changes `accept_bi` to return `BiStream`. |
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | Quinn-welded enums. ADR-092 collapses to thin newtypes, retained for `into_sub_streams()`. |
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection`. ADR-092 replaces with `WsBidiStream` through `Connection::from_bidi`. |
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpc-backed halves. ADR-092: channels crate joins via `tokio::io::join` before `from_bidi`. |
The two `findings.md` Phase 6 issues (QuicStream wrapper, WS bespoke
dispatch) are symptoms of the split leaf. ADR-092 addresses the
transport seam; this doc addresses the multiplexing seam that ADR-092
exposes.
---
## ADR-092 recap (what's settled)
- `accept_bi` / `open_bi` return `BiStream`, not `(SendStream,
RecvStream)`.
- `BiStream` is a concrete newtype (internal `Box<dyn AsyncReadWrite +
Send + Unpin>`), not the bare ADR-007 trait (which is removed; the
bounds survive as implied bounds).
- The join moves into core's quinn/iroh `BidiStreamSource` impls once
(via `tokio::io::join` or equivalent).
- `Connection::from_bidi` is the only public stream constructor.
`Connection::from_stream(send, recv, ...)` is removed — the split
never crosses a crate boundary as part of a constructor.
- `SendStream` / `RecvStream` collapse to thin newtypes over
`Box<dyn Async* + Send + Unpin>`, retained only for `into_sub_streams`
(ADR-074) and the channels reassembly path's `SubStreamHandle` leaves.
- `HttpAdapter::handle` drops `QuicStream` (44 lines) and
`QuicStreamDuplex` (38 lines).
- WS runs through `Connection::from_bidi(WsBidiStream)` + the
call-protocol handler; `WsStream` trait, `drive_ws_session` loop,
and ~150 lines of dispatch glue removed.
- "VPN-like without being a VPN" over WS in v1 becomes real
(`webtransport.md`'s path, over WS).
- WebTransport h3 extraction is recorded as a future channels-variant
move enabled by the unification (out of scope per ADR-044).
ADR-092 is pushed (`f8d4650`, amended `528cfa0`). It is load-bearing
and separable from the multiplexing redesign below.
---
## What ADR-092 exposes: the multiplexing seam
With the transport leaf unified, the remaining asymmetry is in the
*stream_type* space — the multiplexing layer above the transport.
### ADR-071's mod-3 decomposition (current)
ADR-071 groups stream_types in threes:
### ADR-071's current convention: mod-3 groups
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
@@ -153,56 +70,20 @@ ADR-071 groups stream_types in threes:
| 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.
`% 3 == 2` → diagnostic read (err).
**The flaw: stderr is structural.** The "err is the third half"
bakes stderr into the group shape. The "group" concept is real (data
vs control) but the `+2 = err` slot is asymmetric — a bidirectional
thing is two halves, but the group has three slots, one of which is
"err." This forces every group to either allocate an err slot it may
not use (tunnel has no err) or skip the group structure entirely.
**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.
### The control channel flaw in the TTY code
`crates/alknet-tty/src/wire.rs:13,32-33`:
```
STREAM_CONTROL: u8 = 3 // "bidirectional, JSON control message"
InvalidStreamType > 3
```
One stream_type both sides write to. That's the "control isn't
actually bidirectional" flaw ADR-071 §stream_type decomposition calls
out: *one* stream both sides write loses independent flow control,
independent EOF, and clean separation. ADR-077 amends ADR-052 to add
stream_type 4 (ctrl_out), splitting control into 3 (in) + 4 (out).
The TTY code hasn't been updated — it still has the old "bidirectional
3" comment and the `> 3` bound. The update is needed regardless of
the mod-2/mod-3 decision.
### The implicit split (the key insight)
Bidirectionality is *always* two unidirectional halves. There is no
"bidirectional stream_type" — bidirectionality is two stream_types
(write, read), the same way QUIC bidi streams are two unidirectional
halves, the same way `tokio::io::split(BiStream)` is two halves, the
same way `SendStream`/`RecvStream` after ADR-092 is two halves. The
stream_type space is *inherently* unidirectional; the bidirectional
thing is the pair.
This means: TTY and channels present the same shape to their
handlers. A set of unidirectional sub-streams, paired into
bidirectional channels where the handler wants a joined pair. TTY
wants five unidirectional leaves (`stdin`, `stdout`, `stderr`,
`ctrl_in`, `ctrl_out`); the tunnel wants two paired into one
`BiStream` (`tokio::io::join(send_0, recv_1)`). The handler chooses
how to compose the leaves; the channels layer just exposes them.
---
## The instance framing (mod 2 / mod 4)
### Proposed convention: mod-2/mod-4 by instance
An **instance** is a self-contained bidirectional unit, addressed as
a contiguous block of stream_types within a channel.
a contiguous block of `stream_type` values within a channel.
**No control channel** — mod 2:
@@ -214,8 +95,8 @@ a contiguous block of stream_types within a channel.
| ... | | |
| 127 | 254 | 255 |
128 instances per channel. Instance K uses stream_types `2K` (in) and
`2K+1` (out).
128 instances per channel. Instance K uses `stream_type = 2K` (in)
and `2K+1` (out).
**With control channel** — mod 4:
@@ -227,117 +108,115 @@ a contiguous block of stream_types within a channel.
| ... | | | | |
| 63 | 252 | 253 | 254 | 255 |
64 instances per channel. Instance K uses stream_types `4K` (in),
`4K+1` (out), `4K+2` (ctrl_in), `4K+3` (ctrl_out).
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
**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 the handler can have N independent bidirectional sub-streams on
one channel, each with or without its own control.
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 as `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. That's a lot, and it's the
load-bearing count for the "recursive multiplexing" point below.
**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.
**The "ignore stderr for a moment" resolution.** Stderr is the one
feature that breaks the pure mod-2 framing — it's a unidirectional
read (server→client), not a bidirectional pair. Three options:
### Why mod 2 wins
1. **Stderr as its own instance (mod 2).** Instance 0 = io, instance
1 = stderr-as-bidirectional (write half unused, declared but never
written). The demux routes by `(channel_id, stream_type)`
regardless of convention; the per-channel declaration at
`channel/open` time (ADR-073 already has this field) is the only
truth. PTY mode (stderr merged) declares instance 0 only; pipe
mode declares instances 0 and 1. One unused stream_type per
stderr-carrying channel; cheap.
2. **Stderr as a unidirectional leaf (the asymmetric case).** The
per-channel declaration allows unidirectional leaves — not every
stream_type has to be part of a pair. Stderr rides a single
stream_type (e.g., type 5), declared read-only. The demux doesn't
care; the convention is "pairs are the default, unidirectional
leaves are explicitly declared." This keeps mod 2 as the
bidirectional convention without forcing stderr into a pair.
3. **Combine stderr into stdout (the Docker PTY choice).** Always
merge; lose separate stderr. This loses the pipe/non-PTY mode
feature — `tty-local/pipe.rs:363 separate_stderr` test asserts
stderr arrives as distinct chunks; the Docker `exec`/`attach` API
exposes `Tty: true` (merge) vs `Tty: false` (separate). Not
acceptable for the non-PTY use case.
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 stderr question is load-bearing for mod 2 vs mod 3.** If
stderr-as-its-own-instance (option 1) is clean, mod 2 wins — every
bidirectional thing is a pair, stderr is a pair with one unused half,
no asymmetry. If the unused half is a real wart, the per-channel
declaration with unidirectional leaves (option 2) wins, and the
mod-2/mod-3 distinction is less load-bearing than "the declaration is
the truth, the convention is a hint." This is a POC candidate (see
below).
**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
```
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.
**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.
---
## TTY/channels convergence
Channels was written after TTY and was a natural extension: TTY's
5-byte header + `channel_id:u32` prefix = the 9-byte channels header.
The convergence has two parts.
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.
### Format convergence
### Semantic convergence (independent of format)
TTY-direct uses the 5-byte format (ADR-052, scoped to direct by
ADR-077). Channels uses the 9-byte format (ADR-071). TTY-inside-channels
uses the 9-byte format. The 5-byte format is the 9-byte format with
the `channel_id` prefix elided (because TTY-direct has only one
channel).
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 with
`channel_id = 0`.** One demux implementation, one set of stream_type
semantics, one crate. The 4-byte overhead per chunk is noise for the
TTY use case (terminal I/O, not bulk transfer). But: backward-compat
with existing TTY-direct deployments, and the "channel 0 is always
call" rule (ADR-072) conflicts — TTY-direct's single channel is
`alknet/tty`, not `alknet/call`. Resolutions:
**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` (breaking ADR-072's "channel 0 is call" rule).
`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.
- **B3:** TTY-direct is not a channels connection at all; it keeps the
5-byte format (Option A). The 9-byte format applies only inside
channels. (Same as current ADR-077.)
**Option B is the bigger call.** It's a wire-format change with
backward-compat implications. It's a POC candidate (validate B1/B2 in
a minimal implementation) and a possible ADR-094. Not in scope for the
mod-2/mod-4 stream_type redesign (ADR-093).
### Semantic convergence (independent of format)
Regardless of Option A/B, TTY and channels should present the same
shape to their handlers: a set of unidirectional sub-streams
declared per-channel, paired into bidirectional instances where the
handler wants a joined `BiStream`. The `into_sub_streams()` accessor
(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.
This means 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). Both are the same data;
the handler chooses how to compose.
backward-compat implications. It's the one open question that benefits
from a POC — see below.
---
@@ -353,55 +232,40 @@ level is "pairs of stream_types (or 4-tuples with control), declared
per-channel, addressed by instance."
This is a property, not a feature. The primary use case is one level
of multiplexing. Recursive composition is a natural consequence of the
abstraction, not a goal. 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.
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.
---
## POC candidates
Ordered by leverage (the load-bearing unknowns that, once validated,
make the ADR drafting mechanical rather than speculative).
### POC 1: stderr split/recombine — **already answered, no POC needed**
### POC 1: stderr split/recombine (load-bearing for mod 2 vs mod 3)
**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?"
**Question:** Can a `tokio::io::split` half be recombined with a
different half cleanly? Specifically: if stderr is its own instance
(mod 2, write half unused), does the unused write half cause any
real problem — dangling sender, premature EOF on the read half,
flow-control weirdness? Or does declaring "instance 1 = stderr,
write half unused" just work?
**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`).
**Why it matters:** If the unused write half is clean, mod 2 wins
(every bidirectional thing is a pair, stderr is a pair with one
unused half, no asymmetry, no mod 3). If it's a wart, the
per-channel declaration with unidirectional leaves (option 2) wins,
and the mod-2/mod-3 distinction is less load-bearing than "the
declaration is the truth."
**Status:** Confirmatory, not exploratory. The load-bearing property
is already proven. The mod-2 framing is trivially clean. No new POC.
**Scope:** ~100 lines. A test-only POC in the channels POC's shape:
declare a channel with two instances, instance 0 = io pair (types
0/1), instance 1 = stderr (types 2/3, write half never written).
Verify the read half on instance 1 delivers stderr chunks; the write
half never errors; the demux doesn't choke on an unused write
stream_type. Run it over the POC's `tokio::io::duplex` stand-in.
**Output:** A `docs/research/alknet-channels/poc-stderr-split.md`
summary with a yes/no answer and the code path. If yes, ADR-093
drafts with mod 2. If no, ADR-093 drafts with per-channel
declaration as the primary, mod 2 as a convention hint.
### POC 2: TTY-direct-as-channels (load-bearing for format convergence)
### POC 2: TTY-direct-as-channels — the one open question
**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 the backward-compat with existing
TTY-direct deployments a real constraint, or are there no existing
deployments to break?
for terminal I/O? And is backward-compat with existing TTY-direct
deployments a real constraint, or are there no existing deployments
to break?
**Why it matters:** If Option B is clean and backward-compat is
non-binding, the 5-byte format retires, the demux implementation
@@ -410,8 +274,8 @@ 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).
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.
@@ -420,18 +284,17 @@ 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)
### POC 3: recursive composition — low leverage, deferred
**Question:** Does `alknet/channels` inside `alknet/channels` actually
work end-to-end? The abstraction permits it (ADR-074); the POC didn't
**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.
**Scope:** Deferred. Not a POC for this round. Recorded for
completeness.
**Status:** Deferred. Not a POC for this round.
---
@@ -439,62 +302,52 @@ completeness.
| ADR | Scope | Status |
|-----|-------|--------|
| **ADR-092** | `BiStream` as the handler leaf; `accept_bi` returns `BiStream`; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor | **Drafted, pushed** (`f8d4650`, amended `528cfa0`). Load-bearing, separable. |
| **ADR-093** | stream_type as the unidirectional leaf; mod 2 / mod 4 instance framing; per-channel stream_type declaration; `into_sub_streams()` as the primary accessor; demux convention-agnostic. Amends ADR-071 (wire format stream_type decomposition), ADR-074 (`into_sub_streams`), ADR-077 (TTY uses mod 2, control channel fixed). | **Pre-ADR**, drafting after POC 1 (stderr split) validates the mod-2 decision. |
| **ADR-094** | Retire the 5-byte TTY-direct format in favor of 9-byte channels format with `channel_id = 0` (or `channel_id = 1` with channel 0 = call unused). Backward-compat analysis. | **Pre-ADR**, drafting after POC 2 (TTY-direct-as-channels) recommends Option B. If POC 2 recommends Option A, ADR-094 is not drafted and ADR-077 stands. |
ADR-092 is separable from 093/094 — it's the transport seam, not the
multiplexing seam. ADR-093 is the multiplexing redesign that ADR-092
exposes. ADR-094 is the format-convergence call that ADR-093 enables
but doesn't require.
| **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. |
---
## Open questions (for the research iteration)
## Open questions
- **Stderr as instance vs unidirectional leaf.** POC 1 resolves this.
Default assumption: stderr as instance (mod 2, unused write half is
clean). POC validates.
- **Mod 2 vs mod 3.** If POC 1 confirms the unused-write-half is clean,
mod 2 wins. If not, the per-channel declaration becomes primary and
the mod distinction is a hint, not a rule.
- **5-byte format retirement.** POC 2 resolves this. 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.
- **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.
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.
- **WS-as-BiStream for the browser WASM path.** ADR-092 records
`WsBidiStream` as the server-side adapter; the browser-side adapter
is a separate concern (the WASM SDK, not alknet-http). Where does
the browser-side adapter live? Default: separate, not alknet-http.
Resolved at implementation time.
---
## References
- ADR-092: `BiStream` as the handler leaf (drafted, pushed)
- 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-070: `BidiStreamSource` trait (the extension point ADR-092
amends)
- ADR-065: `Connection::from_stream` / `from_bidi` (amended by
ADR-092 — `from_stream` removed, `from_bidi` only)
- ADR-078: two-pump shutdown-on-completion (preserved by ADR-092)
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
deferred `alknet-http` rework; ADR-092 resolves the deferral
- 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, WASM compile check); the shape ADR-093 builds on
(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 ADR-093 fixes
"bidirectional" flaw (implementation lag; fix specified in ADR-077,
subsumed by the mod-4 instance framing)
- `crates/alknet-tty-local/tests/pipe.rs:363 separate_stderr` — the
test that proves separate stderr is a real feature, not vestigial
test that proves separate stderr is a real feature, not vestigial
(resolves under mod 2 as instance 1 with unused write half)