docs(adr): 093 — channels pure channel multiplexing (8-byte header, no stream_type)

Prune the channels spec to reflect the stream-unification resolution
(docs/research/stream-unification/findings.md): the channels wire format
goes from 9 bytes to 8 bytes, the channels layer no longer carries a
stream_type concept, into_sub_streams() is removed, and TTY always uses
its 5-byte format (carried transparently in the channels payload).

ADR-093 is the umbrella decision (the channels-layer consequence of
ADR-092's BiStream handler leaf): every channel is a BiStream, the
handler owns its sub-stream multiplexing, the channels layer routes by
channel_id only. Amends ADR-071 (8-byte header, no stream_type),
ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses
ADR-077 (TTY always 5-byte), and the channels-facing clauses of
ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note
(into_sub_streams preservation subsequently reversed by ADR-093) and
the missing ADR-092 cross-reference on ADR-070.

Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is
decided in ADR-093, the function surface is open; two-way door, low
priority, decision-ready when the channels crate's implementation
begins).

Rewrites the 7 channels spec docs (README, overview, channels-wire,
channels-connection, channels-adapter, channel-operations, channel-client)
to describe the post-amendment shape as current, with the 8-byte header,
the add/strip composition, single accept_bi accessor, BiStream per
channel, and TTY-always-5-byte.

Touch-up cross-references in hub README, client README, ADR-085, and
the OQ-45/47/65 question files (TTY-internal stream_type 3 →
STREAM_CTRL_IN; channels 9-byte → 8-byte).
This commit is contained in:
glm-5.2 committed 2026-07-18 18:10:17 +00:00
1 parent c2b7055a64
commit a3cb44968e
31 files changed
+1501 -519

No files matched your search

+42 -27
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# alknet-channels
@@ -12,16 +12,17 @@ each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
channel 0 and routed through the same `HandlerRegistry` as top-level
connections. The channels layer is a re-framing proxy — it converts between
"one transport stream carrying N channels" (the wire) and "N independent
`AsyncRead + AsyncWrite` handles" (what handlers see) — and it does no
protocol work itself.
`BiStream` handles" (what handlers see) — and it does no protocol work
itself. The channels layer has no `stream_type` concept (ADR-093); the
handler owns its sub-stream multiplexing on the `BiStream` it receives.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
| [channels-wire.md](channels-wire.md) | draft | The 9-byte chunk format (`[channel_id:u32 be][stream_type:u8][length:u32 be][payload]`), stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition |
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074, as amended by ADR-093), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
@@ -30,20 +31,22 @@ protocol work itself.
| ADR | Title | Relevance |
|-----|-------|-----------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, stream_types [0,1]; no special control plane |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-093); substrate-agnostic; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol; no special control plane |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); **reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently** |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-093 builds on |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
@@ -55,16 +58,18 @@ protocol work itself.
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-093); the *function surface* is not |
## Key Design Principles
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
connection, a QUIC bidi stream — they're all `AsyncRead + AsyncWrite`
handles. The differences are only in how they're *opened* (negotiation
via `channel/open` on channel 0) and what *multiplexing layer* carries
them (the 9-byte chunk format). Once normalized, every channel is an
ALPN routed through the same `HandlerRegistry`. See
[overview.md](overview.md) and ADR-071.
connection, a QUIC bidi stream — they're all `BiStream` (a concrete
`AsyncRead + AsyncWrite` newtype, per ADR-092). The differences are only
in how they're *opened* (negotiation via `channel/open` on channel 0)
and what *multiplexing layer* carries them (the 8-byte chunk format).
Once normalized, every channel is an ALPN routed through the same
`HandlerRegistry`. See [overview.md](overview.md) and ADR-071 (as
amended by ADR-093).
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
plane.** The call protocol runs on channel 0 exactly as on a top-level
@@ -76,23 +81,31 @@ protocol work itself.
3. **The channels layer is a re-framing proxy, not a protocol engine.** It
converts between "one transport stream carrying N channels" (the wire)
and "N independent `AsyncRead + AsyncWrite` handles" (what handlers
see). It does no ALPN-specific parsing, no auth, no transport coupling.
This makes it WASM-compatible and transport-agnostic by construction.
See [channels-adapter.md](channels-adapter.md) and ADR-075.
and "N independent `BiStream` handles" (what handlers see). It does no
ALPN-specific parsing, no auth, no transport coupling, and carries no
`stream_type` concept (ADR-093). This makes it WASM-compatible and
transport-agnostic by construction. See [channels-adapter.md](channels-adapter.md)
and ADR-075.
4. **`channel/resources/subscribe` is a `Subscription`, not a polled
4. **The handler owns its sub-stream multiplexing.** The channels layer
yields one `BiStream` per channel; the handler sub-multiplexes it
however it wants (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol). The channels layer carries
the bytes transparently. See [channels-connection.md](channels-connection.md)
and ADR-093.
5. **`channel/resources/subscribe` is a `Subscription`, not a polled
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
(ADR-049, implemented and tested). The first consumer (the hub
aggregating worker resources) needs live updates. Polling would be built
and immediately reworked. See ADR-073.
5. **Bidirectional open.** Either side can open a channel to the other,
6. **Bidirectional open.** Either side can open a channel to the other,
just like the call protocol's operation overlay. The `direction` field
on `channel/open` pins who is the ALPN-server vs ALPN-client. See
ADR-073 §Direction semantics.
6. **Wire-level invariants are contracts, not implementation details.**
7. **Wire-level invariants are contracts, not implementation details.**
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
close ordering) that hang channels silently if underspecified: shutdown
emits a zero-length sentinel; transport close drops all senders; the mux
@@ -101,11 +114,11 @@ protocol work itself.
`channel/close`. See [channels-wire.md](channels-wire.md) and
[channels-adapter.md](channels-adapter.md).
7. **The hub translates, not transparently forwards.** The hub terminates
8. **The hub translates, not transparently forwards.** The hub terminates
channel 0 on both legs, runs `AccessControl::check`, and re-issues
`channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data
channels are byte-forwarded with `channel_id` rewrite. This preserves
the auth model. See ADR-079.
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
within the 8-byte header). This preserves the auth model. See ADR-079.
## References
@@ -115,6 +128,8 @@ protocol work itself.
tests, three validated targets, REQ-CH-01..06 wire-level invariants
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
- `docs/research/alknet-channels/poc-plan.md` — the POC plan
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-channel-multiplexing resolution (ADR-093)
- `/workspace/alknet-channels-poc/` — the POC codebase
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
format (the seed of the channels generalization)
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channel-client.md — ChannelClient
@@ -33,18 +33,18 @@ impl ChannelClient {
/// `Connection` on ALPN `alknet/channels`. This is the
/// transport-agnostic primary constructor: the caller (or a
/// transport-specific dial helper) produces the `Connection` —
/// via `Connection::from_stream`/`from_bidi` (TCP+TLS,
/// WebTransport, SSH `direct-tcpip`), a quinn connection, or any
/// other `AsyncRead + AsyncWrite` source — and this method takes
/// over: installs channel 0 (`alknet/call`), spawns the demux/mux,
/// and returns the client. Mirrors the server side's
/// transport-agnostic `ChannelsAdapter::handle(Connection)` and
/// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
/// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
/// AsyncWrite` source — and this method takes over: installs
/// channel 0 (`alknet/call`), spawns the demux/mux, and returns
/// the client. Mirrors the server side's transport-agnostic
/// `ChannelsAdapter::handle(Connection)` and
/// `CallClient::spawn_dispatch(Connection)`.
///
/// This is the one-way-door API surface (ADR-080). It must not be
/// coupled to a transport — the channels protocol is
/// transport-agnostic (ADR-071, ADR-065), and the client side is
/// half of that protocol.
/// transport-agnostic (ADR-071, as amended by ADR-093; ADR-065,
/// ADR-092), and the client side is half of that protocol.
pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>;
@@ -75,7 +75,6 @@ impl ChannelClient {
pub async fn open_channel(
&self,
alpn: &str,
stream_types: &[u8],
params: Value,
direction: ChannelDirection,
) -> Result<Channel, ChannelError>;
@@ -99,9 +98,8 @@ pub enum ChannelDirection {
pub struct Channel {
pub channel_id: u32,
pub stream_types: Vec<u8>,
/// The sub-streams, accessible via accept_bi() (ADR-074 generic path)
/// or into_sub_streams() (ADR-074 typed path).
/// The channel's BiStream, accessible via the BidiStreamSource
/// (accept_bi — ADR-074 as amended by ADR-093).
pub source: ChannelBidiStreamSource,
}
@@ -121,11 +119,17 @@ pub struct ResourceEntry {
}
```
> **Amendment (ADR-093, 2026-07-18):** the `stream_types` field is
> **removed** from `open_channel`'s signature and from `Channel`. The
> channels layer has no `stream_type` concept (ADR-093) — the handler
> owns its sub-stream multiplexing on the `BiStream` it receives. The
> handler's sub-stream set is implicit in its ALPN's wire format.
## Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol. The channels
protocol is transport-agnostic (ADR-071 substrate modes;
`Connection::from_stream`/`from_bidi`/`from_source` from ADR-065/070 take
protocol is transport-agnostic (ADR-071 substrate modes, as amended by
ADR-093; `Connection::from_bidi`/`from_source` from ADR-065/070/092 take
any `AsyncRead + AsyncWrite`). The client side must not be welded to a
transport — that would repeat the server-side welding ADR-065 explicitly
unwound.
@@ -135,7 +139,7 @@ the one-way-door API surface. It takes a pre-established `Connection` and
takes over channels establishment. The transport is the caller's concern:
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
`from_stream`, a WebSocket carrying `alknet/channels` (the browser path per
`from_bidi`, a WebSocket carrying `alknet/channels` (the browser path per
ADR-044) — all produce a `Connection` that `from_connection` accepts
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
@@ -194,6 +198,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | `stream_types` removed from `open_channel` and `Channel`; handler owns sub-stream multiplexing |
## Open Questions
@@ -206,8 +211,10 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
## References
- ADR-080: ChannelClient (the decision)
- ADR-093: channels pure channel multiplexing (`stream_types` removed)
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-075: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
@@ -22,7 +22,6 @@ Request (on channel 0):
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3],
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
@@ -32,7 +31,6 @@ Request (on channel 0):
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0. See ADR-071 §stream_type decomposition. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
@@ -41,8 +39,7 @@ Response:
```json
{
"output": {
"channel_id": 7,
"stream_types": [0, 1, 2, 3]
"channel_id": 7
}
}
```
@@ -50,13 +47,22 @@ Response:
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
| `stream_types` | `[u8]` | The negotiated set — the responder may narrow the initiator's requested set. |
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
data flows — the same round-trip the call protocol makes for every
operation. All current channel types (TTY, tunnel, SSH) already require a
negotiation round-trip, so the open round-trip is not additive latency.
> **Amendment (ADR-093, 2026-07-18):** the `stream_types` field is **removed**
> from `channel/open`'s input and output. The channels layer has no
> `stream_type` concept (ADR-093) — the handler owns its sub-stream
> multiplexing on the `BiStream` it receives. The handler's sub-stream set
> is implicit in its ALPN's wire format (e.g., TTY's 5-byte format
> declares its own `stream_type` set internally; the channels layer carries
> the bytes transparently). The `channel:stream_type_unavailable` error
> code is removed (the channels layer can't refuse a `stream_type` it
> doesn't know about).
**Error codes** (new `CallError.code` strings, not new framing):
| code | meaning | retryable |
@@ -66,7 +72,6 @@ negotiation round-trip, so the open round-trip is not additive latency.
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false |
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
### `channel/close` — tear down a channel
@@ -78,7 +83,7 @@ negotiation round-trip, so the open round-trip is not additive latency.
```
The responder (the side that didn't send the close) drains its reassembled
streams for `channel_id`, signals EOF to the handler, and returns
stream for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
`reason` is free-form for observability — not semantically required.
@@ -87,9 +92,11 @@ completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
MUST be written and flushed before the `channel/close` operation is sent on
channel 0. The side closing must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant (ADR-055) carried forward; for tunnels it is the last data byte
before close. This invariant crosses two channels (the data channel and
channel 0), so the channels layer owns the ordering guarantee.
invariant (ADR-055) carried forward — the exit control message rides on
TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0
@@ -101,7 +108,6 @@ keepalive):
"operation": "channel/control",
"input": {
"channel_id": 7,
"stream_type": 3,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
@@ -111,9 +117,16 @@ The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does
not interpret it.
> **Amendment (ADR-093, 2026-07-18):** the `stream_type` field is **removed**
> from `channel/control`'s input. Under ADR-093, the channels layer has no
> `stream_type` concept — the control message is routed to the handler's
> control handle (an ALPN-specific concept the handler owns), not to a
> channels-layer `(channel_id, stream_type)` reassembly buffer. The
> handler decides what to do with the message.
### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-049), not a polled `Query`.** The
**This is a `Subscription` operation (ADR-049), not a polled Query.** The
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop.
@@ -192,14 +205,26 @@ collision-prone client-assigned alternative.
| Control path | When | Examples |
|--------------|------|----------|
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
| `stream_type 3` chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — it rides on `stream_type 3` because it
must arrive after the last stdin chunk, guaranteed by chunk ordering within
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
`channel/close` operation that follows is on channel 0 and is ordered after
the data pump completes (REQ-CH-06).
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
(stream_type 4, inside TTY's 5-byte payload format) because it must arrive
after the last data on TTY's stdout stream_type, guaranteed by TTY's
per-stream_type chunk ordering within its own 5-byte format, not by a
call-protocol round-trip. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**The control-message division is handler-internal.** Under ADR-093, the
channels layer has no `stream_type` concept — it carries the handler's
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by `channel_id` only; the handler owns its
sub-stream multiplexing on the `BiStream` it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
## ACL flow (end-to-end)
@@ -259,6 +284,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | `stream_types` field removed from `channel/open`; `stream_type` removed from `channel/control`; handler owns sub-stream multiplexing |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-adapter.md — ChannelsAdapter and ChannelManager
@@ -8,13 +8,15 @@ last_updated: 2026-07-12
The two internal components of the channels crate: the read/demux half
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
ADR-075 is the decision; this doc specifies the contracts and the demux/mux
invariants.
invariants. The channels layer has no `stream_type` concept (ADR-093) —
the demux routes by `channel_id` only, and the reassembly buffer is one
per channel (not per `(channel_id, stream_type)`).
## The split
| Component | Role | What it knows |
|-----------|------|---------------|
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes, as amended by ADR-093). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
@@ -34,33 +36,35 @@ impl ProtocolHandler for ChannelsAdapter {
// 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream
// the transport yields is channel 0. The consumer (channels-call)
// installs the CallAdapter on it.
let (send, recv) = connection.accept_bi().await?;
self.manager.preinstall_channel_0(send, recv, auth).await?;
let bidi = connection.accept_bi().await?;
self.manager.preinstall_channel_0(bidi, auth).await?;
// 2. Accept remaining bidi streams and read 9-byte headers off each.
// 2. Accept remaining bidi streams and read 8-byte headers off each.
// On an in-line transport, accept_bi() yields once and the header
// demuxes N channels from that stream. On QUIC native, accept_bi()
// yields repeatedly — each stream carries one logical channel.
// Same code path, same wire format (ADR-071 §substrate modes).
// Same code path, same wire format (ADR-071 §substrate modes,
// as amended by ADR-093).
self.manager.run_demux_loop(connection).await
}
}
```
The `preinstall_channel_0` step (provided by `channels-call`, ADR-081)
constructs the reassembly buffers for `channel_id = 0` using stream_types
[0, 1] (ADR-072), wraps them as a `Connection` via `Connection::from_source`
with a `ChannelBidiStreamSource` (ADR-074), and hands that `Connection` to
the `CallAdapter`. The `ChannelsAdapter` in `channels-core` exposes the
hook; `channels-call` provides the implementation.
constructs the reassembly buffer for `channel_id = 0`, wraps it as a
`Connection` via `Connection::from_source` with a
`ChannelBidiStreamSource` (ADR-074, as amended by ADR-093 — `accept_bi`
yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
provides the implementation.
`run_demux_loop` continues accepting bidi streams from the transport. For
each stream, it reads 9-byte headers and routes payloads to the matching
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
there is only one stream (channel 0 rides inside it via the header); the
header demuxes all channels. On QUIC, each subsequent stream is a new
channel; the header's `channel_id` correlates it. The loop is the same;
only the transport's stream count differs.
each stream, it reads 8-byte headers and routes payloads to the matching
`channel_id`'s reassembly buffer. On an in-line transport, there is only
one stream (channel 0 rides inside it via the header); the header demuxes
all channels. On QUIC, each subsequent stream is a new channel; the
header's `channel_id` correlates it. The loop is the same; only the
transport's stream count differs.
## `ChannelManager`
@@ -79,9 +83,11 @@ pub struct ChannelManager {
struct ChannelState {
alpn: String,
streams: HashMap<u8, ReassemblyBuffer>,
/// One reassembly buffer per channel (not per (channel_id, stream_type) —
/// the channels layer has no stream_type concept per ADR-093). Yields
/// a BiStream to the handler.
reassembly: ReassemblyBuffer,
handler_task: JoinHandle<()>,
stream_types: Vec<u8>,
}
```
@@ -90,13 +96,13 @@ struct ChannelState {
all hold a handle.
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are
> the public API surface (contract). `ReassemblyBuffer`, `Demux`,
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and
> `ChannelOperations` are illustrative internal type names — the channels
> crate's implementation may name them differently. The contracts are the
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are
> not contractual.
> `ChannelBidiStreamSource`, and `ChannelClient` are the public API
> surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
> `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
> illustrative internal type names — the channels crate's implementation
> may name them differently. The contracts are the invariants
> (REQ-CH-01..04, 06) and the public API; the internal names are not
> contractual.
### `ChannelManager` is ALPN-blind and auth-blind
@@ -107,8 +113,9 @@ The `ChannelManager` deliberately does **not** hold:
their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH frames, or tunnel target strings. It hands `params` JSON to the
handler and gets back a handler task; it hands `stream_type 3` JSON to the
handler's control handle.
handler and gets back a handler task. The channels layer carries the
handler's framing transparently in the payload — it does not interpret
the payload bytes.
- **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in
@@ -116,6 +123,10 @@ The `ChannelManager` deliberately does **not** hold:
- **No transport coupling.** It talks to the transport only through the
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
which use `AsyncRead + AsyncWrite`.
- **No `stream_type` concept.** Per ADR-093, the channels layer routes by
`channel_id` only. There is one reassembly buffer per channel (yielding
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
owns its sub-stream multiplexing on the `BiStream` it receives.
This is what makes the channels layer WASM-compatible and transport-agnostic
— the `ChannelManager` is pure byte routing with no platform or protocol
@@ -139,8 +150,8 @@ The `channel/open` handler (ADR-073):
missing.
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned).
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated
`stream_types`.
4. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
ADR-093) — one reassembly buffer, yielding a `BiStream`.
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
Identical to what `TtyAdapter::handle` does today, but on a
channels-backed `Connection`.
@@ -152,7 +163,7 @@ The `channel/open` handler (ADR-073):
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
`ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
EOF even without an explicit zero-length sentinel on the wire. Without this,
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
sender that never drops. This is a teardown invariant of the
@@ -160,11 +171,10 @@ sender that never drops. This is a teardown invariant of the
### REQ-CH-04: lenient unknown-`channel_id` handling
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with
a debug log and an error counter (exposed via `Demux::stats()`), and the
demux continues. This matches SSH's behavior and survives transient
mis-ordering during teardown. Validated by the POC
(`demux_unknown_channel_drops_lenient`).
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
## Mux invariants (REQ-CH-03)
@@ -177,8 +187,8 @@ after the run loop starts.
The mux is split into:
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
Sender<Bytes>` callable at any time after the runner starts.
- **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
callable at any time after the runner starts.
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
and per-channel write pumps.
@@ -222,19 +232,20 @@ channels connections:
```rust
// For channel_id=7 on browser side, channel_id=12 on spoke side:
tokio::spawn(async move {
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
let mut b_bidi = browser_mgr.open_channel_stream(7).await;
let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
tokio::join!(
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite)
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite)
pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
);
});
```
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
and writes them onto the other's write-half, which re-chunks them with the
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates
The relay reads opaque bytes off one `ChannelManager`'s reassembled
`BiStream` and writes them onto the other's write-half, which re-chunks
them with the other leg's `channel_id` (a 4-byte rewrite within the
8-byte header). The relay does not parse the payload — it doesn't know if
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
`channel/open` on channel 0 (re-issues on the spoke leg with
`forwarded_for`); data channels are byte-forwarded with `channel_id`
rewrite. See ADR-079 for the full relay contract.
@@ -246,6 +257,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
@@ -253,9 +265,14 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
## References
- ADR-075: ChannelsAdapter and ChannelManager (the decision)
- ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
channel, as amended by ADR-093)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock)
(REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-multiplexing resolution
@@ -1,37 +1,31 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
How a reassembled channel is presented to its handler as a `Connection`.
ADR-074 is the decision; this doc specifies the API shape and the two
access paths.
ADR-074 (amended by ADR-093) is the decision; this doc specifies the API
shape — one accessor, one `BiStream` per channel.
## What
Each channel is reassembled into a set of **unidirectional** handles — one
per active `stream_type` (declared at `channel/open` time, ADR-073). Every
stream_type is unidirectional (ADR-071 §stream_type decomposition);
bidirectionality is two stream_types (write + read), not one shared
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`.
Each channel is reassembled into a `BiStream` — a single duplex
(`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
8-byte header (`channel_id` + `length`) on read, hands the payload to the
reassembled `BiStream`, and the handler parses its own framing from the
payload. The handler sub-multiplexes its `BiStream` however it wants —
TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
runs its own channel protocol.
These handles are wrapped as a `ChannelBidiStreamSource` that implements
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`.
The handler receives a `Connection` and can either:
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
control-out).
Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them.
constructed from it via `Connection::from_source(source, alpn)`. The
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
channel), gets a `BiStream`, and drives its session — identical to how it
works on a top-level QUIC connection.
## `ChannelBidiStreamSource`
@@ -39,29 +33,30 @@ handler accesses them.
// In alknet-channels:
pub struct ChannelBidiStreamSource {
// The reassembly buffers for this channel's active stream_types,
// plus the mux handle for writing back onto the transport.
// Constructed by ChannelManager::build_channel_connection (ADR-075).
// The reassembly buffer for this channel's payload bytes (one per
// channel_id, not per (channel_id, stream_type) — the channels layer
// has no stream_type concept), plus the mux handle for writing back
// onto the transport. Constructed by ChannelManager::build_channel_connection
// (ADR-075).
...
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
-> Result<BiStream, StreamError>
{
// Yields the (stream_type 0, stream_type 1) pair on first call,
// Yields the channel's BiStream on first call,
// ConnectionClosed on subsequent calls. Yield-once per channel,
// matching the POC's validated shape.
}
async fn open_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
-> Result<BiStream, StreamError>
{
// StreamClosed — a single channel cannot open new application
// streams (same as ADR-065's Stream backend). Additional sub-streams
// (stream_type 2, 3) are accessed via into_sub_streams(), not
// open_bi().
// streams (same as ADR-065's Stream backend). The handler owns
// its sub-stream multiplexing on the BiStream it received.
}
fn remote_addr(&self) -> Option<SocketAddr> { ... }
@@ -75,19 +70,23 @@ whole channels connection). The `ChannelManager` (ADR-075) constructs one
per channel at `channel/open` time and wraps it in a `Connection` via
`from_source`.
## The generic path: `accept_bi()`
## The single path: `accept_bi()`
For handlers that only need the main data pair (`stream_type` 0 = data-in,
`stream_type` 1 = data-out):
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. There is one accessor; the two-accessor design
(`accept_bi` vs `into_sub_streams`) from ADR-074's original shape is
removed by ADR-093.
```rust
// Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let (mut send, mut recv) = connection.accept_bi().await?;
let mut bidi = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
let (mut recv, mut send) = tokio::io::split(&mut bidi);
// Two-pump with shutdown-on-completion (ADR-078)
let c2t = async {
@@ -105,98 +104,53 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
}
```
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
pair, and pumps. It does not know it's inside a channels connection — the
`Connection` looks like any other. This is the path the POC's `EchoHandler`
and `TunnelHandler` validated.
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
calls return `ConnectionClosed`. This matches the POC's validated shape and
the `StreamBidiStreamSource` yield-once contract (ADR-070).
## The typed path: `into_sub_streams()`
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1:
```rust
// In alknet-channels-core:
pub struct ChannelSubStreams {
/// (stream_type, handle) for each active stream_type. Each handle is
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
/// See ADR-071 §stream_type decomposition.
pub streams: Vec<(u8, SubStreamHandle)>,
}
pub enum SubStreamHandle {
Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
}
impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3/4.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
// TTY handler (inside-channels mode, ADR-077 reversed by ADR-093) —
// the SAME code as direct mode, just a different BiStream source.
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let mut bidi = connection.accept_bi().await?;
// drive_session reads the 5-byte TTY chunks off `bidi` — the same
// code as direct mode. The channels layer stripped its 8-byte
// header; TTY's 5-byte format is the payload.
drive_session(bidi, backends, ownership, identity).await
}
```
The handler crate destructures `ChannelSubStreams` into its typed names:
The handler calls `accept_bi()` once, gets a `BiStream`, and pumps. It
does not know it's inside a channels connection — the `Connection` looks
like any other. This is the path the POC's `EchoHandler` and
`TunnelHandler` validated.
```rust
// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
```
**Every stream_type is unidirectional** (ADR-071). The channels crate
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
`into_sub_streams()` consumes the source — a handler can't call both
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
include the 0/1 pair, so `into_sub_streams()` is the superset.
## Choosing the path
| Handler shape | Path | Examples |
|---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
| Needs stderr/control (2/3/4) | `into_sub_streams()` | TTY (stdin/stdout/stderr/ctrl-in/ctrl-out) |
The handler chooses based on its ALPN's `stream_type` set (declared at
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
`Connection` (via `from_source`); handlers that need sub-streams access the
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
(exact ergonomics are an implementation detail for the channels crate; the
contract is that both paths are available and the handler crate chooses).
`accept_bi()` is yield-once: the first call returns the `BiStream`;
subsequent calls return `ConnectionClosed`. This matches the POC's
validated shape and the `StreamBidiStreamSource` yield-once contract
(ADR-070, ADR-092).
## Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
from_source` wraps it. A handler that is itself `alknet/channels` can open a
sub-channels connection on a data channel — `alknet/channels` inside
`alknet/channels`. This is allowed (the `Connection` abstraction permits it)
but not a feature designed for. The primary use case is one level of
multiplexing. Recursive composition is a natural consequence of the
abstraction, not a goal.
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and
`Connection::from_source` wraps it. A handler that is itself
`alknet/channels` can open a sub-channels connection on a data channel —
`alknet/channels` inside `alknet/channels`. 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 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 an 8-byte header) at every level, not a different framing per
level.
## What does NOT change
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
internal to the channels crate; handlers see a `Connection`.
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to
wrap their internal sources. `ChannelBidiStreamSource` constructs them via
the existing `from_stream` constructors, backed by mpsc reassembly
buffers.
- **`BiStream`** (ADR-092) — the leaf type `accept_bi` returns. The
channels layer yields `BiStream`s; handlers parse them per their ALPN.
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
the same registry as top-level connections.
@@ -206,15 +160,22 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed, `accept_bi` is the only accessor) |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References
- ADR-074: ChannelConnection (the decision)
- ADR-074: ChannelConnection (the decision, amended by ADR-093)
- ADR-093: channels pure channel multiplexing (the umbrella decision)
- ADR-070: BidiStreamSource trait
- ADR-065: `Connection::from_stream`
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
- ADR-092: `BiStream` as the handler leaf
- ADR-065: `Connection::from_stream` (the yield-once path generalized)
- ADR-077: TTY inside channels (reversed by ADR-093 — TTY always uses
its 5-byte format, carried transparently in the channels payload)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
yield-once `Connection::from_stream` validation)
yield-once `Connection::from_stream` validation)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the single-accessor resolution
+176 -137
View File
@@ -1,147 +1,135 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-wire.md — The 9-Byte Chunk Format
# channels-wire.md — The 8-Byte Chunk Format
The wire format for `alknet/channels`: a 9-byte chunk header that
multiplexes N logical channels, each with up to 256 sub-stream types, over
a single ordered, reliable bidirectional transport stream. ADR-071 is the
The wire format for `alknet/channels`: an 8-byte chunk header that
multiplexes N logical channels over a single ordered, reliable
bidirectional transport stream. ADR-071 (amended by ADR-093) is the
decision; this doc specifies the format and the wire-level invariants.
The channels layer has no `stream_type` concept — not in its header, not
in its code, not in its mental model. The handler owns its sub-stream
multiplexing on the `BiStream` the channels layer gives it.
## Chunk header
```
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
9 bytes of header, followed by `length` bytes of payload.
8 bytes of header, followed by `length` bytes of opaque payload.
| 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-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
| `stream_type` | 4 | 1 | The sub-stream within the channel. See "Stream types" below. |
| `length` | 5 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
This is a 4-byte extension of alknet-tty's 5-byte format (ADR-052): the
`channel_id` prefix is added; `stream_type` and `length` are identical. The
`ChunkReader` / `ChunkWriter` pattern, the framing-disambiguation trick,
and the zero-length sentinel convention all carry forward from TTY.
The payload is opaque to the channels layer. The handler parses its own
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
framing, ADR-064), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
### How the wire formats compose
The channels 8-byte header and the handler's framing compose by layering:
```
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 channels layer reads its 8-byte header (`channel_id` + `length`),
reads `length` bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (`stream_type` + `length`) from the payload bytes.
The two length fields are close but not identical: `ch_len = tty_len + 5`.
This is a small amount of waste per chunk (the channels `length` is always
5 bytes more than TTY's `length`), but the trade-off is 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. See ADR-093 for the full cost/benefit analysis.
## `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
header.
## Stream types — unidirectional, grouped in threes
**Every stream_type is unidirectional.** Bidirectionality is two
stream_types (write + read), not one "bidirectional" stream_type. The
stream_types are grouped in threes:
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
| | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
| | 4 | read (server→client) | control out (ALPN-specific format) |
| | 5 | read (server→client) | control err (optional) |
| Future | 6/7/8 | write/read/read | next group, same pattern |
| | ... | | |
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
256 values / 3 = 85 groups. The `u32` channel_id space combined with 85
stream_type groups is effectively unlimited for the intended use cases.
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
own flow control, its own EOF. Control is bidirectional via two halves
(3 in, 4 out), not one shared stream both sides write to. This resolves the
TTY control channel's "not actually bidirectional" flaw (ADR-077).
**Control payload format is ALPN-specific.** The channels layer is blind to
what stream_types 3/4/5 carry — it reassembles bytes and delivers them to
the handler. TTY happens to use JSON for its control channel; another ALPN
might use a binary format. The channels layer does not mandate JSON on
control stream_types, the same way it doesn't mandate a format for data
stream_types.
Not all channels use all sub-streams. The active set is declared at
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
channel's lifetime.
| Channel ALPN | Active stream_types | Why |
|--------------|---------------------|-----|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
## Substrate modes — same wire format, different stream counts
The 9-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the transport
yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
each stream is a channel, and the header provides `stream_type` and
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes, ADR-075.
exactly 8 bytes, so the demux can always resync by reading the next
8-byte header.
## Channel 0 — pre-negotiated `alknet/call`
Channel 0 is not a special "control plane" with its own framing. It is
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
routed to the `CallAdapter` without an explicit `channel/open` exchange.
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0`
is routed to the `CallAdapter` without an explicit `channel/open`
exchange.
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
(client→server), 1=out (server→client). The call protocol's `(SendStream,
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are
reserved for future call-protocol sub-streams.
Channel 0's chunks have `channel_id = 0` in the 8-byte header — same
format as every other channel. The call protocol's `EventEnvelope` JSON
framing (ADR-064) is the payload; the channels layer carries it
transparently. Disambiguation between channel 0 and data channels is by
`channel_id`, not by a special first-byte trick.
Channel 0's chunks have `channel_id = 0` in the header — same format as
every other channel. Disambiguation between channel 0 and data channels is
by `channel_id`, not by a special first-byte trick.
## Framing disambiguation
## Framing disambiguation (from ADR-052 §5)
The 9-byte header is always exactly 9 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 9 bytes, parses the header, reads
The 8-byte header is always exactly 8 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 8 bytes, parses the header, reads
`length` bytes of payload, and routes. If a chunk is dropped (e.g.,
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
`ChunkTooLarge`), the demux resyncs by reading the next 8-byte header —
the format is self-synchronizing.
Within a channel, `stream_type` 0 (write half) from the server is invalid,
so `0x00` as the first byte of a chunk payload from the server is
unambiguous (carried from ADR-052 §5).
There is no channels-layer framing-disambiguation trick beyond the fixed
8-byte header. The channels layer does not interpret the payload — it
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
Any framing disambiguation within the payload is the handler's concern
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
which is internal to TTY's 5-byte format).
## Zero-length sentinel = EOF
A zero-length chunk (`length = 0`) is delivered as an empty `Bytes`, which
the reassembled stream interprets as EOF. This is the clean-shutdown signal
for a `(channel_id, stream_type)` pair — same convention as TTY (ADR-052
§Sentinels).
A zero-length chunk (`length = 0`) is delivered as an empty payload,
which the reassembled stream interprets as EOF. This is the clean-shutdown
signal for a `channel_id` — the same convention as TTY (ADR-052
§Sentinels), now at the channels layer (one sentinel per channel, not
per `(channel_id, stream_type)`).
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
EOF.
## Substrate modes — same wire format, different stream counts
The 8-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the
transport yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 8-byte header → route by `channel_id` → reassemble into a `BiStream`.
On an in-line transport, `accept_bi()` yields once then
`ConnectionClosed` — the header does all the demux. On QUIC, `accept_bi()`
yields repeatedly — each stream is a channel, and the header provides
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes (as amended by ADR-093), ADR-075.
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
@@ -151,22 +139,23 @@ These are **contracts**, not implementation details — both sides must agree.
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
send an empty `Bytes` (the EOF sentinel) before dropping the sender on
send an empty payload (the EOF sentinel) before dropping the sender on
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
channel's `stream_type`, and `tokio::io::copy` in the handler never
channel, and `tokio::io::copy` in the handler never
completes — the session hangs.
The TTY crate's `pump_session` emits the zero-length stdout sentinel
explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's
per-channel write pump does NOT forward a sentinel on sender-drop, so the
send adapter must. Both sides must agree on this convention, or channels
hang on clean shutdown.
explicitly via its own 5-byte format's zero-length chunk; the channels
layer's per-channel write pump does NOT forward a sentinel on
sender-drop, so the send adapter must. Both sides must agree on this
convention, or channels hang on clean shutdown.
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
The demux loop MUST clear its `channels` map on transport EOF, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
EOF even without an explicit zero-length sentinel arriving on the wire.
The demux loop MUST clear its `channels` map on transport EOF, dropping
all `ReassemblyBuffer` senders. Every handler's reassembled `BiStream`
sees EOF even without an explicit zero-length sentinel arriving on the
wire.
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
waiting for a sender that never drops because the demux task is holding the
@@ -174,11 +163,11 @@ map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
A chunk with an unallocated `channel_id` (or `stream_type` on an allocated
channel) is dropped with a debug log and an error counter (exposed via
`Demux::stats()`), and the demux continues. This matches SSH's behavior and
survives transient mis-ordering during teardown (a chunk for a channel that
was just closed may arrive after the close is processed).
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown (a chunk for a channel that was just closed may arrive after
the close is processed).
The alternative (strict — close the transport on unknown `channel_id`) is
fragile during teardown and catches bugs at the cost of reliability. The
@@ -187,10 +176,10 @@ fragility.
### REQ-CH-05: bounded-buffer backpressure does not deadlock
Each `(channel_id, stream_type)` has an independent bounded `mpsc` buffer
(default 1 MiB — ADR-076). A slow reader on one channel does not block
another channel's reads — the demux's per-chunk route awaits the matching
sender without holding a global lock.
Each `channel_id` has an independent bounded `mpsc` buffer (default 1 MiB
— ADR-076). A slow reader on one channel does not block another channel's
reads — the demux's per-chunk route awaits the matching sender without
holding a global lock.
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
channel writer faster than the TCP echo server consumer, with no deadlock
@@ -204,17 +193,16 @@ The wire format's core is pure byte manipulation:
```rust
// wire.rs — sync core, no async, no platform deps, WASM-clean
const CHUNK_HEADER_LEN: usize = 9;
const CHUNK_HEADER_LEN: usize = 8;
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
pub struct ChunkHeader {
pub channel_id: u32,
pub stream_type: u8,
pub length: u32,
}
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... }
pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
```
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
@@ -223,13 +211,35 @@ routing. The split keeps the WASM-compatible core separate from the
tokio-dependent shell. The POC validated the sync core compiles under
`wasm32-unknown-unknown`.
## 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.
The exact API shape of the add/strip pair (built into the read/write path
vs. a standalone utility) 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; the *function surface* is not.
## Channel lifecycle (summary)
| Phase | Mechanism | Reference |
|-------|-----------|-----------|
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 |
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees `AsyncRead + AsyncWrite` | this doc, [channels-connection.md](channels-connection.md) |
| Control (data-ordered) | `stream_type 3` (write) and `stream_type 4` (read) chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees a `BiStream` | this doc, [channels-connection.md](channels-connection.md) |
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-073 |
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-073, REQ-CH-06 |
@@ -240,24 +250,53 @@ The channel's data chunks MUST be written and flushed before the
invariant: the side closing must observe the data-channel pump complete
before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
the exit control message on `stream_type 4` (read, server→client) is the
last data before `channel/close`. For tunnels it is the last data byte
before close. The channels layer's close handler observes the pump
completion; the call operation is issued after.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
For tunnels it is the last data byte before close. The channels layer's
close handler observes the pump completion; the call operation is issued
after.
This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler
concern.
concern. The control-message division (data-ordered control vs
out-of-band control) is now entirely handler-internal: TTY's
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` are stream_types in TTY's 5-byte
payload format, not channels-layer concepts.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
## Open Questions
Open questions are tracked in [open-questions.md](../../open-questions.md).
Key questions affecting this doc:
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided; the *function surface* is not.
## References
- ADR-071: channels wire format (the decision)
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
amended by ADR-077 — scoped to direct TTY)
- ADR-071: channels wire format (the decision, amended by ADR-093 — 8-byte
header, no `stream_type`)
- ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload)
- ADR-072: channel 0 pre-negotiated
- ADR-073: channel lifecycle operations
- ADR-076: backpressure, channel limits, ID reuse
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
Surfaced #4-#6 (REQ-CH-01, 02, 04)
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
- `docs/research/stream-unification/findings.md` — the research that
surfaced the 8-byte format decision
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
(carried transparently in the channels payload)
+75 -38
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# alknet-channels — Overview
@@ -9,17 +9,20 @@ last_updated: 2026-07-12
`alknet-channels` is a multiplexing proxy crate. It implements
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one
bidirectional transport stream, reads 9-byte chunk headers, and routes each
bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled
into an `AsyncRead + AsyncWrite` pair and presented to its handler as a
`Connection` — the handler doesn't know it's inside a channels connection.
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
ADR-092) and presented to its handler as a `Connection` — the handler
doesn't know it's inside a channels connection.
Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel
is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed
through the same `HandlerRegistry` as top-level connections. The channels
layer does no protocol work itself — it is a re-framing proxy that converts
between "one transport stream carrying N channels" (the wire) and "N
independent stream handles" (what handlers see).
independent `BiStream` handles" (what handlers see). The channels layer has
no `stream_type` concept (ADR-093) — the handler owns its sub-stream
multiplexing on the `BiStream` it receives.
## Why
@@ -46,13 +49,13 @@ With `alknet/channels`, one connection carries everything:
```
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
```
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
@@ -70,12 +73,35 @@ The collapse is at three levels:
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
with no new auth.
### The separation: 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 (ADR-093).
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-092). The handler sub-multiplexes it however it
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
bytes, SSH's channel protocol.
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
header, not in its code, not in its mental model. `stream_type` is the
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control.
- **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.
## Architecture
The crate has two internal components (ADR-075):
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection`, reads 9-byte
`alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
half.
- **`ChannelManager`** — the shared state. Holds `channel_id →
@@ -84,9 +110,10 @@ The crate has two internal components (ADR-075):
`channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074). The
handler calls `accept_bi()` once (yield-once per channel) and drives its
session — identical to how it works on a top-level QUIC connection.
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074, as
amended by ADR-093). The handler calls `accept_bi()` once (yield-once per
channel) and gets a `BiStream` — identical to how it works on a top-level
QUIC connection.
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
design.
@@ -96,7 +123,7 @@ design.
```
alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
│ BidiStreamSource, BiStream, AuthContext)
├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads)
├── async-trait
@@ -159,7 +186,7 @@ stream:
The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint.
`Connection::from_stream` / `from_source` (ADR-065/070) handles the
`Connection::from_bidi` / `from_source` (ADR-065/070/092) handles the
transport-agnostic `Connection` construction.
## WASM compatibility
@@ -189,7 +216,9 @@ not an architecture concern. The sync core's WASM compatibility is validated.
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
runs on channel 0 exactly as on a top-level `alknet/call` connection. The
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
and dispatches operations — it doesn't know it's inside channels.
and dispatches operations — it doesn't know it's inside channels. The call
protocol's `EventEnvelope` framing (ADR-064) is the channels payload; the
channels layer carries it transparently.
What changes: the call protocol gains a new class of operations — channel
lifecycle (ADR-073). These are registered on the `OperationRegistry` at
@@ -198,24 +227,27 @@ assembly time and dispatched through the existing `OperationContext` /
### alknet-tty
The TTY crate gains a `channels` feature (ADR-077) that enables
inside-channels mode. In direct mode (`alknet/tty` ALPN on a top-level
connection), the TTY adapter uses its own 5-byte wire format (ADR-052,
unchanged). In channels mode (`channel/open` with ALPN `alknet/tty`), the
adapter receives `ChannelSubStreams` (ADR-074) — four named
`SendStream`/`RecvStream` pairs for stream_types 0-3 — and pumps without
chunk parsing. The `TtyBackend` trait and `TtyHandle` are unchanged;
The TTY crate gains a `channels` feature that enables inside-channels
mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and
inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY
adapter uses its own 5-byte wire format (ADR-052). The two modes differ
only in *where the `BiStream` comes from* — a top-level connection vs a
channels-backed `Connection`. The same `wire.rs` code runs in both modes
(ADR-077, reversed by ADR-093): the channels layer strips its 8-byte
header and hands TTY the payload bytes; TTY parses its 5-byte header from
the payload. The `TtyBackend` trait and `TtyHandle` are unchanged;
backends don't know which mode the adapter is in.
### alknet-ssh (future)
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
protocol over stream_types 0 and 1. The channels layer hands the
reassembled stream to `SshAdapter`, which feeds it to russh. SSH as a
channels transport: an SSH `direct-tcpip` channel could carry a channels
connection (channels-over-SSH). The SSH crate doesn't need to know about
channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`.
protocol on its `BiStream`. The channels layer hands the reassembled
`BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels
transport: an SSH `direct-tcpip` channel could carry a channels connection
(channels-over-SSH). The SSH crate doesn't need to know about channels —
it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`. SSH multiplexes internally (its own channel protocol rides
the channels payload transparently).
### alknet-docker
@@ -240,16 +272,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); **reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently** |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
## Open Questions
@@ -267,4 +300,8 @@ Key questions affecting this crate:
blocked on a real HOL-blocking deployment observation.
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing.
two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-093); the *function surface* is not.
+2 -1
View File
@@ -439,7 +439,8 @@ closed (no CA to fall back to — ADR-034 §3, Assumption 1).
### Non-Rust native clients (out of scope)
The wire protocols (channels 9-byte chunk format — ADR-071; call
The wire protocols (channels 8-byte chunk format — ADR-071, as amended
by ADR-093; call
`EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint
uses X.509 (the web endpoint type, or a native endpoint with X.509
instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python,
+6 -1
View File
@@ -814,11 +814,16 @@ See [open-questions.md](../../open-questions.md) for full details.
ALPN would serve the same role over QUIC/TCP without HTTP.
- **OQ-65** (open): WebSocket carrying channels — whether the browser
path extends from call-protocol-only (ADR-048) to full channels
(the 9-byte chunk format over WebSocket binary frames). If chosen,
(the 8-byte chunk format over WebSocket binary frames). If chosen,
the browser is a first-class channels participant and the hub relay
works unchanged for browser legs. The web endpoint advertises
`alknet/channels` by default (ADR-086 §3 — the advertisement is
settled; OQ-65 governs whether the browser path uses it).
- **OQ-68** (open): Channels add/strip API shape — whether the 8-byte
header add/strip is built into the channels read/write path or
exposed as a standalone utility. The *contract* is decided (ADR-093);
the *function surface* is not. Does not block the hub (the hub uses
the `ChannelManager` interface either way).
- **OQ-52** (open): `CallConnection::wait_for_close()` — the
supervision loop needs a way to await connection close. The
committed interim is polling `connection().accept_bi()` until