Files
alkcall/docs/architecture/decisions/035-channels-pure-channel-multiplexing.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

485 lines
26 KiB
Markdown

# ADR-035: alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
## Status
Accepted (amends ADR-034 — wire format is 8 bytes, not 9, and the channels
layer has no `stream_type` concept; amends ADR-038 — `into_sub_streams()`
removed, `accept_bi` is the only accessor and yields one `BiStream` per
channel; reverses ADR-077 — TTY always uses its 5-byte format, the channels
layer carries it transparently in the payload)
## Context
ADR-034 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). ADR-038 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-077 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
(`docs/research/stream-unification/findings.md`, 2026-07-18) 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 ADR-034 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.
ADR-038'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 ADR-009, 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-052 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
`alknet/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 `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
### Why this can land now
Three things changed since ADR-034/074/077 were accepted:
1. **ADR-009 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 ADR-009's handler-leaf decision — the research-then-sync
pattern applied: ADR-009 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-077'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 ADR-044, 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, tracked as OQ-68. The *contract* — the channels layer
strips its 8-byte header on read and the handler parses its own framing
from the payload — is decided 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.
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
windowing is an additive extension that doesn't change the wire
format. OQ-56 stays deferred(scope).
## 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 `alknet/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §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 ADR-034: 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-052, amended by Phase 7), which the channels
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
ADR-038's `into_sub_streams()` / `ChannelSubStreams` / `SubStreamHandle`
are removed. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-009). 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 ADR-038: the two-accessor design (`accept_bi` for generic
handlers, `into_sub_streams` for typed handlers) collapses to one
accessor. The "typed handler path" (ADR-038'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. ADR-038'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-077's two-mode TTY design (direct vs inside-channels) is reversed.
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-052) 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 `alknet/tty` connection vs a `channel/open` with ALPN
`alknet/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-077: 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 `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 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
`alknet/channels`-inside-`alknet/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its own
8-byte header from the payload — same code, same shape, each level.
### 5. What does NOT change
- **ADR-009's `BiStream` leaf** — unchanged. This ADR is the
channels-layer consequence of ADR-009: `accept_bi` yields a `BiStream`,
handlers sub-multiplex it. The two ADRs compose (ADR-009 settles the
transport leaf; this ADR settles the multiplexing layer above it).
- **`ProtocolHandler` trait shape** (ADR-002) — unchanged. Handlers
receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alknet/call`** (ADR-036) — 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** (ADR-037) — 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** (ADR-039) —
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** (ADR-040) — 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** (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** (ADR-042) — 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`** (ADR-043) — 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** (ADR-044) — 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** (ADR-008) — unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per ADR-009, 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 `alknet/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 ADR-034'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 ADR-038 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-077's `drive_session_direct` / `drive_session_channels`
split collapses to one `drive_session` function. The `channels` feature
on `alknet-tty` becomes a thin wrapper that gets the `BiStream` from a
channels-backed `Connection` instead of a top-level one.
- **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).
- **`channel/open` loses the `stream_types` field.** ADR-037'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 (ADR-040) 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-077 (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 (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
- ADR-034: 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)
- ADR-038: ChannelBidiStreamSource (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 in the payload;
the two-mode design is preserved but differs only in `BiStream`
source, not in parsing)
- ADR-009: `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)
- ADR-008: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- ADR-036: channel 0 pre-negotiated `alknet/call` (unchanged — channel 0's
chunks have `channel_id = 0` in the 8-byte header; the call protocol's
framing is the payload)
- ADR-037: channel lifecycle operations (amended — `stream_types` field
removed from `channel/open`; `channel:stream_type_unavailable` error
code removed)
- ADR-039: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per
channel)
- ADR-040: backpressure, channel limits, ID reuse (unchanged —
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-042: hub relay (unchanged in contract — 8-byte header, 4-byte
`channel_id` rewrite, payload byte-forwarded)
- ADR-043: `ChannelClient` (amended — `stream_types` field removed from
`open_channel` and `Channel`)
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
`channels-core`; call-protocol coupling in `channels-call`)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload; the control channel split
from Phase 7 is TTY-internal)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the structural question and the resolution this ADR commits
- `docs/research/alknet-crate-extraction/findings.md` Phase 8 — the
spec-cleanup phase this ADR is the substance of
- `/workspace/alknet-channels-poc/` — the POC that validated the
per-`channel_id`/`stream_type` routing mechanism (the mechanism
supports any convention; this ADR says the channels layer doesn't have
a convention, the handler does)