docs(arch): channels substrate simplification + stream_type decomposition + sub-crate split

Three simplifications to the channels spec, all flowing from the review:

1. Substrate simplification (ADR-071 revised): the 9-byte chunk header is
   used in ALL substrates — in-line (TCP+TLS, WebTransport), native (QUIC
   bidi streams), and multi-connection. The ChannelsAdapter reads headers
   off every bidi stream it accepts; the transport's native multiplexing
   is a performance optimization (independent flow-control windows), not a
   protocol change. One wire format, one code path, one handler experience.
   The channel_id in the header is the correlation key across substrates.

2. Stream_type decomposition (ADR-071 revised): every stream_type is
   unidirectional. Bidirectionality is two stream_types (write + read), not
   one 'bidirectional' stream_type. Grouped in threes: 0/1/2 = data
   write/read/err, 3/4/5 = control write/read/err, % 3 formula. This
   resolves the TTY control channel's 'not actually bidirectional' flaw —
   control is now 3 (write, client→server) + 4 (read, server→client), each
   with its own reassembly buffer and EOF. Channel 0 uses [0,1] (call frames
   bidirectional via 0=in, 1=out). TTY uses [0,1,2,3,4]. ADR-072, 073, 074,
   077 updated for the new stream_type assignments.

3. Sub-crate decomposition (ADR-081 new): channels-core (pure multiplexer —
   wire format, demux/mux, ChannelBidiStreamSource, ChannelManager; depends
   on alknet-core only, no call dependency, ALPN-blind) / channels-call
   (channel 0 pre-negotiation + lifecycle op registrations; depends on
   channels-core + alknet-call) / channels-hub (relay) / channels-worker
   (ChannelClient). Isolates the call-protocol coupling from the pure
   multiplexer so the dependency graph is honest.

ADR-077 (TTY inside channels) updated: TTY now uses 5 sub-streams [0,1,2,3,4]
with control properly bidirectional via 3/4; amends ADR-052's stream_type
assignments for direct mode too (direct alknet/tty now uses 0-4, not 0-3).

Spec docs updated: channels-wire, channels-connection, channels-adapter,
channel-operations, overview, README.
This commit is contained in:
glm-5.2 committed 2026-07-12 15:47:42 +00:00
1 parent 2313c51f12
commit fd83fc1685
14 files changed
+734 -251

No files matched your search

+32 -17
View File
@@ -9,35 +9,43 @@ last_updated: 2026-07-12
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing **alknet-channels specs drafted.** The alknet-channels crate (multiplexing
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
channels over one transport stream, channel 0 pre-negotiated as channels over transport stream(s), channel 0 pre-negotiated as
`alknet/call`) now has architecture specs: `alknet/call`) now has architecture specs:
[crates/channels/](crates/channels/) (overview, channels-wire, [crates/channels/](crates/channels/) (overview, channels-wire,
channels-connection, channels-adapter, channel-operations, channel-client) channels-connection, channels-adapter, channel-operations, channel-client)
and ten ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte and eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
chunk header), [ADR-072](decisions/072-channel-0-pre-negotiated-call.md) chunk header; revised for substrate simplification — the header is used in
(channel 0 = `alknet/call` pre-negotiated, no special control plane), all substrates including QUIC native, not just in-line; and stream_type
decomposition — every stream_type is unidirectional, grouped in threes:
0/1/2 = data write/read/err, 3/4/5 = control write/read/err, `% 3` formula;
resolves the TTY control channel's "not actually bidirectional" flaw),
[ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
(channel 0 = `alknet/call` pre-negotiated, stream_types [0,1] — call frames
bidirectional via 0=in, 1=out),
[ADR-073](decisions/073-channel-lifecycle-operations.md) (channel [ADR-073](decisions/073-channel-lifecycle-operations.md) (channel
lifecycle operations on the call protocol — `channel/open`/`close`/ lifecycle operations on the call protocol — `channel/open`/`close`/
`control`/`resources/subscribe`; `channel/resources/subscribe` is a `control`/`resources/subscribe`; `channel/resources/subscribe` is a
`Subscription` operation using the already-implemented `StreamingHandler` `Subscription` operation using the already-implemented `StreamingHandler`
machinery, not a polled `Query`; the `direction` field pins who is the machinery, not a polled `Query`; the `direction` field pins who is the
ALPN-server; the control-message division is call-ops for orchestration, ALPN-server; the control-message division is call-ops for orchestration,
`stream_type 3` for data-ordered control), `stream_type 3`/`4` for data-ordered control),
[ADR-074](decisions/074-channelconnection-bidistreamsource.md) [ADR-074](decisions/074-channelconnection-bidistreamsource.md)
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's (`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
extension point; `into_sub_streams()` typed accessor for handlers that need extension point; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv
stderr/control; `accept_bi()` generic path for tunnel/SSH), per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH),
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md) [ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
(`ChannelsAdapter` read/demux + `ChannelManager` reassemble/allocate split; (`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off
REQ-CH-01..04 wire-level invariants pinned: shutdown emits zero-length every bidi stream, regardless of substrate) + `ChannelManager`
sentinel, transport close drops all senders, mux dynamic registration, reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
lenient unknown-`channel_id`), shutdown emits zero-length sentinel, transport close drops all senders, mux
dynamic registration, lenient unknown-`channel_id`),
[ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md) [ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md)
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs (bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
with wrap-around), with wrap-around),
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses [ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses
sub-streams, not its own 5-byte wire format; ADR-052's scope amended to sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4]
direct-connect TTY only; `channels` feature on alknet-tty), with control properly bidirectional via 3 (write) + 4 (read); ADR-052's
scope amended to direct-connect TTY only; `channels` feature on alknet-tty),
[ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump [ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump
handlers MUST shut down the opposite sink on pump completion — the handlers MUST shut down the opposite sink on pump completion — the
deadlock contract the POC surfaced; handler-level, not channels-layer; deadlock contract the POC surfaced; handler-level, not channels-layer;
@@ -46,10 +54,16 @@ core helper extraction deferred per OQ-57),
translates `channel/open` on channel 0 with `forwarded_for` — ADR-032; translates `channel/open` on channel 0 with `forwarded_for` — ADR-032;
data channels byte-forwarded with `channel_id` rewrite; the hub never runs data channels byte-forwarded with `channel_id` rewrite; the hub never runs
protocol-specific handlers), protocol-specific handlers),
[ADR-080](decisions/080-channelclient.md) (`ChannelClient` in [ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
alknet-channels, QUIC-only initially, bidirectionality preserved; QUIC-only initially, bidirectionality preserved;
`AlknetClient` core extraction stays deferred per OQ-55 — blocked on a `AlknetClient` core extraction stays deferred per OQ-55 — blocked on a
second *transport's* client, not a second client). The specs are grounded second *transport's* client, not a second client),
[ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate
decomposition — `channels-core` (pure multiplexer, depends on alknet-core
only, no call dependency) / `channels-call` (channel 0 pre-negotiation +
lifecycle op registrations, depends on channels-core + alknet-call) /
`channels-hub` (relay) / `channels-worker` (ChannelClient); isolates the
call-protocol coupling from the pure multiplexer). The specs are grounded
in the completed de-risk POC in the completed de-risk POC
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three (`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
validated targets: chunk format + demux/mux, per-channel `Connection` validated targets: chunk format + demux/mux, per-channel `Connection`
@@ -64,7 +78,7 @@ blocking observation) and OQ-57 (two-pump helper extraction — blocked on a
second two-pump handler). The TTY integration (ADR-077) amends ADR-052's second two-pump handler). The TTY integration (ADR-077) amends ADR-052's
scope — the 5-byte format is unchanged for direct `alknet/tty` connections; scope — the 5-byte format is unchanged for direct `alknet/tty` connections;
inside channels, TTY uses `into_sub_streams()` and the channels layer's inside channels, TTY uses `into_sub_streams()` and the channels layer's
de-chunking. de-chunking, with control properly bidirectional via stream_types 3/4.
**Pre-implementation of the storage/repo pattern.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains `alknet-vault` (stable — implementation complete and verified, local-only by construction per ADR-025, HD-derivation key model per ADR-026) and research/reference material. Foundational ADRs (001–035) are in place, with the call crate implemented and reviewed. **Pre-implementation of the storage/repo pattern.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains `alknet-vault` (stable — implementation complete and verified, local-only by construction per ADR-025, HD-derivation key model per ADR-026) and research/reference material. Foundational ADRs (001–035) are in place, with the call crate implemented and reviewed.
@@ -249,6 +263,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted | | [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted | | [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted | | [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
## Open Questions ## Open Questions
+7 -6
View File
@@ -30,16 +30,17 @@ protocol work itself.
| ADR | Title | Relevance | | ADR | Title | Relevance |
|-----|-------|-----------| |-----|-------|-----------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; one-way door | | [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, no special control plane | | [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 |
| [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 | | [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()` accessor | | [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 contracts | | [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 | | [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); 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); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
| [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 | | [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 | | [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` in alknet-channels, QUIC-only; `AlknetClient` deferred (OQ-55) | | [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, QUIC-only; `AlknetClient` deferred (OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling) / hub / worker |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements | | [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides 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 the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
@@ -32,7 +32,7 @@ Request (on channel 0):
| field | type | meaning | | field | type | meaning |
|-------|------|---------| |-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. | | `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]` for TTY, `[0,1]` for a tunnel. | | `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`. | | `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. | | `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
@@ -14,14 +14,14 @@ invariants.
| Component | Role | What it knows | | Component | Role | What it knows |
|-----------|------|---------------| |-----------|------|---------------|
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off the transport and routes to `ChannelManager` | The transport stream; the `ChannelManager` handle. ALPN-blind. | | `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. |
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`, `OperationRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over. | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). | | `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 The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
pattern, generalized to N channels: the adapter drives N channels, and pattern, generalized to N channels: the adapter drives N channels, and
channel 0 is special only in that it's pre-allocated. channel 0 is special only in that it's pre-allocated (by `channels-call`).
## `ChannelsAdapter::handle` ## `ChannelsAdapter::handle` (substrate-agnostic)
```rust ```rust
#[async_trait] #[async_trait]
@@ -31,46 +31,47 @@ impl ProtocolHandler for ChannelsAdapter {
async fn handle(&self, connection: Connection, auth: &AuthContext) async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError> -> Result<(), HandlerError>
{ {
// 1. One bidi stream carries all channels. // 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?; let (send, recv) = connection.accept_bi().await?;
// 2. Channel 0 is pre-negotiated as alknet/call (ADR-072).
// Construct reassembly buffers, wrap as a Connection via
// from_source(ChannelBidiStreamSource), hand to the CallAdapter.
self.manager.preinstall_channel_0(send, recv, auth).await?; self.manager.preinstall_channel_0(send, recv, auth).await?;
// 3. Run the demux loop. // 2. Accept remaining bidi streams and read 9-byte headers off each.
self.manager.run_demux_loop(recv).await // 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).
self.manager.run_demux_loop(connection).await
} }
} }
``` ```
### `preinstall_channel_0` 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.
The only special case: constructs the reassembly buffers for `channel_id = `run_demux_loop` continues accepting bidi streams from the transport. For
0`, wraps them as a `Connection` (via `Connection::from_source` with a each stream, it reads 9-byte headers and routes payloads to the matching
`ChannelBidiStreamSource` — ADR-070/074), and hands that `Connection` to `(channel_id, stream_type)` reassembly buffer. On an in-line transport,
the `CallAdapter` — exactly as if `alknet/call` had been the top-level there is only one stream (channel 0 rides inside it via the header); the
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every header demuxes all channels. On QUIC, each subsequent stream is a new
other ALPN. The `CallAdapter` is none the wiser: it calls `accept_bi()`, channel; the header's `channel_id` correlates it. The loop is the same;
gets one bidi stream (the channel-0 reassembled stream), and runs its only the transport's stream count differs.
dispatch loop. `EventEnvelope` frames ride on `stream_type = 0` of channel 0.
### `run_demux_loop`
Reads 9-byte headers off the transport, looks up `channel_id` in the
`ChannelManager`'s `channels` map, and pushes the payload into the right
`ReassemblyBuffer` for `(channel_id, stream_type)`. If the buffer is full
(bounded-buffer backpressure, ADR-076), the loop stops reading that
channel's chunks until the consumer drains — other channels keep flowing.
## `ChannelManager` ## `ChannelManager`
```rust ```rust
// In alknet-channels-core:
pub struct ChannelManager { pub struct ChannelManager {
channels: Mutex<HashMap<u32, ChannelState>>, channels: Mutex<HashMap<u32, ChannelState>>,
handlers: Arc<HandlerRegistry>, handlers: Arc<HandlerRegistry>,
call_ops: Arc<OperationRegistry>, // Note: no call_ops field — the call-protocol coupling lives in
// channels-call (ADR-081). The ChannelManager is ALPN-blind and
// call-protocol-blind.
next_id: AtomicU32, // monotonic; wraps at u32::MAX next_id: AtomicU32, // monotonic; wraps at u32::MAX
buffer_cap: usize, // default 1 MiB (ADR-076) buffer_cap: usize, // default 1 MiB (ADR-076)
max_channels: usize, // default 256 (ADR-076) max_channels: usize, // default 256 (ADR-076)
@@ -11,8 +11,13 @@ access paths.
## What ## What
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles Each channel is reassembled into a set of **unidirectional** handles — one
—one per active `stream_type` (declared at `channel/open` time, ADR-073). 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`.
These handles are wrapped as a `ChannelBidiStreamSource` that implements These handles are wrapped as a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is `alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`. constructed from it via `Connection::from_source(source, alpn)`.
@@ -21,9 +26,9 @@ The handler receives a `Connection` and can either:
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) — 1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
the generic handler path (tunnel, SSH). the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all 2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SendStream, RecvStream)` active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/ the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
control). control-out).
Both paths operate on the same reassembly buffers; the difference is how the Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them. handler accesses them.
@@ -115,20 +120,24 @@ For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1: to 0/1:
```rust ```rust
// In alknet-channels: // In alknet-channels-core:
pub struct ChannelSubStreams { pub struct ChannelSubStreams {
/// (stream_type, send_half, recv_half) for each active stream_type. /// (stream_type, handle) for each active stream_type. Each handle is
pub streams: Vec<(u8, SendStream, RecvStream)>, /// 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)>,
} }
impl ChannelSubStreams { pub enum SubStreamHandle {
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... } Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
} }
impl ChannelBidiStreamSource { impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the /// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs /// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3. /// direct access to stream_types 2/3/4.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... } pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
} }
``` ```
@@ -138,15 +147,16 @@ The handler crate destructures `ChannelSubStreams` into its typed names:
```rust ```rust
// In alknet-tty (inside-channels mode, ADR-077): // In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams(); let sub = channel_source.into_sub_streams();
let stdin = sub.get(0).unwrap(); // SendStream let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let stdout = sub.get(1).unwrap(); // RecvStream let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get(2); // Option<&RecvStream> let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let control = sub.get(3).unwrap(); // RecvStream (JSON control) let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
``` ```
**The channels crate does not know about TTY's `stream_type` semantics.** **Every stream_type is unidirectional** (ADR-071). The channels crate
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
maps stream_types to its typed names. This preserves ADR-003's stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind. ALPN-blind.
@@ -159,7 +169,7 @@ include the 0/1 pair, so `into_sub_streams()` is the superset.
| Handler shape | Path | Examples | | Handler shape | Path | Examples |
|---------------|------|---------| |---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) | | Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY (stdin/stdout/stderr/control) | | 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 The handler chooses based on its ALPN's `stream_type` set (declared at
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a `channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
@@ -37,26 +37,64 @@ 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 exactly 9 bytes, so the demux can always resync by reading the next 9-byte
header. header.
## Stream types ## Stream types — unidirectional, grouped in threes
| stream_type | direction | purpose | **Every stream_type is unidirectional.** Bidirectionality is two
|-------------|-----------|---------| stream_types (write + read), not one "bidirectional" stream_type. The
| 0 | write half | data flowing in (stdin equivalent) | stream_types are grouped in threes:
| 1 | read half | data flowing out (stdout equivalent) |
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) | | Group | stream_type | direction | purpose |
| 3 | bidirectional | control messages (ALPN-specific JSON) | |-------|-------------|-----------|---------|
| 4-255 | reserved | future sub-stream types | | 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 (resize, signal, eof) |
| | 4 | read (server→client) | control out (exit, keepalive response) |
| | 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).
Not all channels use all sub-streams. The active set is declared at 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/open` time (ADR-073 `stream_types` field) and fixed for the
channel's lifetime. channel's lifetime.
| Channel ALPN | Active stream_types | | Channel ALPN | Active stream_types | Why |
|--------------|---------------------| |--------------|---------------------|-----|
| `alknet/call` (channel 0) | 0 (EventEnvelope frames) | | `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | 0, 1, 2, 3 (stdin, stdout, stderr, control) | | `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | 0, 1 (data-in, data-out) | | `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | 0, 1 (data-in, data-out — SSH multiplexes internally) | | `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.
## Channel 0 — pre-negotiated `alknet/call` ## Channel 0 — pre-negotiated `alknet/call`
@@ -64,9 +102,11 @@ 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 `alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
routed to the `CallAdapter` without an explicit `channel/open` exchange. routed to the `CallAdapter` without an explicit `channel/open` exchange.
Channel 0 uses only `stream_type` 0 for `EventEnvelope` frames (JSON, Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
length-prefixed — the call protocol wire format, ADR-064). `stream_type` (client→server), 1=out (server→client). The call protocol's `(SendStream,
1-255 on channel 0 are reserved for future call-protocol sub-streams. 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 header — same format as 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 every other channel. Disambiguation between channel 0 and data channels is
@@ -80,9 +120,9 @@ The 9-byte header is always exactly 9 bytes. `length` is bounded by
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header — `ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
the format is self-synchronizing. the format is self-synchronizing.
Within a channel, `stream_type` 0 (stdin) from the server is invalid, so Within a channel, `stream_type` 0 (write half) from the server is invalid,
`0x00` as the first byte of a chunk payload from the server is unambiguous so `0x00` as the first byte of a chunk payload from the server is
(carried from ADR-052 §5). unambiguous (carried from ADR-052 §5).
## Zero-length sentinel = EOF ## Zero-length sentinel = EOF
@@ -182,7 +222,7 @@ tokio-dependent shell. The POC validated the sync core compiles under
|-------|-----------|-----------| |-------|-----------|-----------|
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 | | 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) | | 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` chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 | | 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 |
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-073 | | 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 | | Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-073, REQ-CH-06 |
@@ -194,10 +234,10 @@ invariant: the side closing must observe the data-channel pump complete
before issuing the call operation. before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward: For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
the exit control message on `stream_type 3` is the last data before the exit control message on `stream_type 4` (read, server→client) is the
`channel/close`. For tunnels it is the last data byte before close. The last data before `channel/close`. For tunnels it is the last data byte
channels layer's close handler observes the pump completion; the call before close. The channels layer's close handler observes the pump
operation is issued after. completion; the call operation is issued after.
This invariant crosses two channels (the data channel and channel 0), so This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler the channels layer owns the ordering guarantee — it is not a handler
+38 -18
View File
@@ -94,29 +94,48 @@ design.
## Crate dependencies ## Crate dependencies
``` ```
alknet-channels alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry, ├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, SendStream, RecvStream, AuthContext) │ BidiStreamSource, SendStream, RecvStream, AuthContext)
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
│ make_streaming_handler, CallError, ResponseEnvelope)
├── tokio (spawn, mpsc, io) ├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads) ├── bytes (Bytes for chunk payloads)
├── async-trait ├── async-trait
├── thiserror ├── thiserror
└── tracing └── tracing
alknet-channels-call
├── alknet-channels-core (ChannelManager, ChannelsAdapter,
│ ChannelBidiStreamSource)
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
│ make_streaming_handler, CallError, ResponseEnvelope)
└── tokio
alknet-channels-hub (or the hub role within alknet-hub)
├── alknet-channels-call
└── alknet-call (from_call, CallAdapter, forwarded_for — ADR-079)
alknet-channels-worker (or the worker/client role)
├── alknet-channels-call
└── alknet-call (CallClient-equivalent)
``` ```
`alknet-channels` depends on `alknet-call` because channel lifecycle `alknet-channels-core` is the pure multiplexer — wire format, demux/mux,
operations (`channel/open`, `channel/close`, `channel/control`, `ChannelBidiStreamSource`, `ChannelManager`. It depends on `alknet-core`
`channel/resources/subscribe`) are registered on the call protocol's only. No `alknet-call` dependency. ALPN-blind, call-protocol-blind,
`OperationRegistry` (ADR-073). This is the one handler-crate → transport-blind. This is where the "streams are streams" insight lives.
`alknet-call` dependency; it is sound because channels *is* a call-protocol
extension (channel lifecycle is call operations), not a peer handler.
The dependency direction is: handlers depend on `alknet-core`; `alknet-channels-call` is the call-protocol coupling — channel 0
`alknet-channels` depends on `alknet-core` and `alknet-call`; nothing pre-negotiation as `alknet/call` (ADR-072) and the four lifecycle operations
depends on `alknet-channels` except the assembly layer and (ADR-073) registered on the call protocol's `OperationRegistry`. This is
`alknet-tty`/`alknet-docker` behind their `channels`/`tty` feature gates. where the call-protocol coupling lives, isolated from the pure multiplexer.
`alknet-channels-hub` and `alknet-channels-worker` (or the hub/worker roles
within `channels-call`) are the two roles: the relay (ADR-079) and the
`ChannelClient` (ADR-080). Whether these are separate sub-crates or
feature-gated modules within `channels-call` is a two-way-door packaging
decision (ADR-081).
See ADR-081 for the full decomposition rationale.
## ALPN ## ALPN
@@ -220,16 +239,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; one-way door | | [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`, no special control plane | | [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned | | [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()` accessor | | [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 | | [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 | | [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); ADR-052 scoped to direct | | [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level | | [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 | | [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; QUIC-only; `AlknetClient` deferred (OQ-55) | | [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling) / hub / worker |
## Open Questions ## Open Questions
@@ -2,14 +2,15 @@
## Status ## Status
Accepted Accepted (revised 2026-07-12: substrate simplification + stream_type
decomposition)
## Context ## Context
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on `alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
`alknet/channels` that decomposes a single bidirectional transport stream into `alknet/channels` that carries N logical channels, each with a different
N logical channels, each carrying a different ALPN. The wire format is the ALPN, over transport stream(s). The wire format is the substrate that makes
substrate that makes one transport stream carry many channels. this work.
Two prior formats inform this design: Two prior formats inform this design:
@@ -18,16 +19,47 @@ Two prior formats inform this design:
encrypted transport stream. encrypted transport stream.
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be] 2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control) [payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
within one bidi stream. Validated by two POCs (alknet-docker-poc, within one bidi stream. Validated by two POCs and in production code.
alknet-tty-poc) and in production code (`crates/alknet-tty/src/wire.rs`).
The channels format is the generalization: add a `channel_id: u32` prefix to The channels format is the generalization: add a `channel_id: u32` prefix to
TTY's 5-byte header, turning a fixed 4-channel multiplexer into an arbitrary TTY's 5-byte header, turning a fixed 4-channel multiplexer into an arbitrary
N-channel multiplexer. The de-risk POC (`docs/research/alknet-channels/poc- N-channel multiplexer. The de-risk POC (28 tests) validated the core
summary.md`, 28 tests) validated this is a clean generalization: the sync core mechanics.
(`parse_header`/`write_header`) is pure and WASM-compatible by construction;
the mpsc-bridged async shell scales to N concurrent channels with per-channel ### The substrate simplification
order preservation and cross-channel isolation.
Different transports have different native multiplexing capabilities. QUIC
has native bidi streams; TCP+TLS does not. The initial framing of this ADR
treated the 9-byte header as "the in-line substrate format" — used when the
transport has no native multiplexing, with a separate substrate for QUIC
native streams. That framing adds complexity (two substrates, a
`channel_id`↔stream-ID mapping) for no benefit.
The simplification: **the 9-byte header is used in all substrates, on every
bidi stream that carries channels.** The `channel_id` in the header is the
logical correlation key. The transport's native multiplexing (when present)
is a performance optimization (independent flow-control windows per
stream), not a protocol change. The `ChannelsAdapter` reads the 9-byte
header off every bidi stream it accepts — on a transport with one stream
(in-line), the header demuxes N channels from that stream; on a transport
with N streams (QUIC native), each stream carries one logical channel and
the header provides `stream_type` and `channel_id` correlation. Same code
path, same wire format, same handler experience.
### The stream_type decomposition
The initial framing had `stream_type` 3 as "bidirectional" (control
messages). This is a design flaw: one stream_type both sides write to is not
properly multiplexed — it loses independent flow control, independent EOF,
and clean separation of concerns. The TTY crate's control channel already
exhibits this problem ("the control channel isn't actually bidirectional…
the adapter ignores Exit from the client" — phase-0 findings §Less
Straightforward Parts).
The fix: **every stream_type is unidirectional.** Bidirectionality is
achieved by having two stream_types (one write, one read), the same way
QUIC bidi streams are two unidirectional halves. Control becomes 3 (write,
client→server) and 4 (read, server→client), not one "bidirectional" 3.
## Decision ## Decision
@@ -37,47 +69,119 @@ order preservation and cross-channel isolation.
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes] [channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
``` ```
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte format; 9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
`stream_type` and `length` are identical to TTY's fields (ADR-052), preserving format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
the framing-disambiguation soundness property (§5 below). preserving the framing-disambiguation soundness property.
| field | width | meaning | | field | width | meaning |
|-------|-------|---------| |-------|-------|---------|
| `channel_id` | u32 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`. | | `channel_id` | u32 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`. |
| `stream_type` | u8 | The sub-stream within the channel. 0=stdin/data-in, 1=stdout/data-out, 2=stderr/diagnostic (optional), 3=control (JSON), 4-255 reserved. | | `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). | | `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
### `MAX_CHUNK_LEN` ### `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with `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 `length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. This preserves the framing- stream — the demux drops the chunk and continues. The header is always
disambiguation soundness property: the header is always exactly 9 bytes, so exactly 9 bytes, so the demux can always resync by reading the next 9-byte
the demux can always resync after a dropped chunk by reading the next 9-byte
header. header.
### Stream types (per channel) ### Stream types — unidirectional, grouped in threes
| stream_type | direction | purpose | **Every stream_type is unidirectional.** Bidirectionality is two
|-------------|-----------|---------| stream_types (write + read), not one "bidirectional" stream_type. The
| 0 | write half | data flowing in (stdin equivalent) | stream_types are grouped in threes:
| 1 | read half | data flowing out (stdout equivalent) |
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
| 3 | bidirectional | control messages (ALPN-specific JSON) |
| 4-255 | reserved | future sub-stream types |
Not all channels use all sub-streams. A TTY session uses 0-3. A raw tunnel | Group | stream_type | direction | purpose |
uses 0 and 1. An SSH connection uses 0 and 1 (SSH multiplexes internally). |-------|-------------|-----------|---------|
The active `stream_type` set is declared at `channel/open` time | Data | 0 | write (client→server) | data in (stdin equivalent) |
(ADR-073) and fixed for the channel's lifetime. | | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (resize, signal, eof) |
| | 4 | read (server→client) | control out (exit, keepalive response) |
| | 5 | read (server→client) | control err (optional) |
| Future | 6/7/8 | write/read/read | next group, same pattern |
| | 9/10/11 | write/read/read | next group |
| | ... | | |
**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 (~4 billion channels
before wrap, ADR-076) 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. The TTY control channel becomes *actually*
bidirectional because there are two unidirectional streams (3 in, 4 out),
not one stream both sides write to. This resolves the "control channel
isn't actually bidirectional" problem the TTY crate has today. The same
principle applies to any future channel type — control is two halves, not
one shared stream.
### Per-ALPN stream_type sets
| 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 |
The active set is declared at `channel/open` time (ADR-073 `stream_types`
field) and fixed for the channel's lifetime. A tunnel that wants keepalive
could declare [0, 1, 3, 4].
### 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.
**Why keep the header on QUIC when the stream already separates channels:**
- **`stream_type` decomposition.** TTY needs 5 sub-streams. On QUIC, one
stream per channel + the header carrying `stream_type` is simpler than 5
streams per channel and matches the in-line case's shape. Per-stream-type
flow control is handled at the reassembly buffer level (ADR-076)
regardless of substrate.
- **`channel_id` correlation.** The hub relay forwards a channel from the
browser leg to the spoke leg, mapping `browser_id ↔ spoke_id`. If
`channel_id` is in the header on every stream, the relay correlates by
reading the header — regardless of substrate. If `channel_id` lived only
in the `channel/open` response, the relay would need a stream-ID-to-
`channel_id` mapping per leg.
- **Uniform handler experience.** The handler receives a `Connection` and
calls `accept_bi()` or `into_sub_streams()`. It doesn't know or care
whether the substrate is in-line or native — the
`ChannelBidiStreamSource` wraps the reassembled stream either way.
The "bloat" (9 bytes/chunk on transports that have native multiplexing) is
the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
operations), this is noise. The high-throughput escape hatch is
multi-connection, not stripping the header.
### Framing disambiguation (carried from ADR-052 §5) ### Framing disambiguation (carried from ADR-052 §5)
Channel 0 is just another channel — its chunks have `channel_id=0` in the Channel 0 is just another channel — its chunks have `channel_id=0` in the
header. Disambiguation between channel 0 (call protocol) and data channels is header. Disambiguation between channel 0 (call protocol) and data channels
by `channel_id`, not by a special first-byte trick. Within a channel, is by `channel_id`, not by a special first-byte trick. Within a channel,
`stream_type` 0 (stdin) from the server is invalid, so `0x00` as the first `stream_type` 0 (write half) from the server is invalid, so `0x00` as the
byte of a chunk payload from the server is unambiguous. first byte of a chunk payload from the server is unambiguous.
### Zero-length sentinel = EOF ### Zero-length sentinel = EOF
@@ -87,61 +191,70 @@ is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
### Sync core / async shell split ### Sync core / async shell split
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9]) -> The wire format's core is pure byte manipulation — `parse_header(&[u8; 9])
ChunkHeader` and `write_header(channel_id, stream_type, length, &mut [u8; 9])`. -> ChunkHeader` and `write_header(channel_id, stream_type, length, &mut
No async, no platform dependencies. Compiles under `wasm32-unknown-unknown` [u8; 9])`. No async, no platform dependencies. Compiles under
(validated by the POC). The async shell (demux/mux) wraps this core with `wasm32-unknown-unknown` (validated by the POC). The async shell (demux/mux)
`read_exact`/`write_all` on the transport and `mpsc` routing. This split is wraps this core with `read_exact`/`write_all` on the transport and `mpsc`
the same pattern as TTY's (REQ-TTY-01 generalization) and keeps the routing. This split keeps the WASM-compatible core separate from the
WASM-compatible core separate from the tokio-dependent shell. tokio-dependent shell.
## Consequences ## Consequences
**Positive:** **Positive:**
- One multiplexing model replaces three (connection-level ALPN, stream-level - One wire format across all substrates. The `ChannelsAdapter` code path is
QUIC native, sub-stream-level TTY chunks). The hub's relay logic becomes the same regardless of transport; the transport's native multiplexing is a
channel-by-channel byte forwarding, not per-protocol framing parsers. performance optimization, not a protocol change.
- The 9-byte overhead is negligible for the intended use cases (TTY sessions, - Every stream_type is unidirectional — proper multiplexing with independent
SSH, tunnels, call operations). The format is a 4-byte extension to a flow control and EOF per half. The TTY control channel becomes actually
proven 5-byte format. bidirectional (3 in, 4 out), resolving the "not actually bidirectional"
flaw.
- The hub relay correlates by `channel_id` in the header, uniformly across
substrates — no stream-ID-to-`channel_id` mapping per leg.
- WASM-compatible by construction — the pure core has no platform deps. - WASM-compatible by construction — the pure core has no platform deps.
- The framing-disambiguation property from ADR-052 carries forward unchanged. - The framing-disambiguation property from ADR-052 carries forward unchanged.
**Negative:** **Negative:**
- All channels on one `alknet/channels` connection share one transport - 9 bytes per chunk on all substrates, including QUIC where the transport
stream's flow-control window. A slow consumer on one channel can already separates streams. This is 4 bytes more than using the QUIC stream
backpressure others. This is mitigated by bounded-buffer backpressure ID directly as the `channel_id`. For the intended use cases this is noise;
(ADR-076) but not eliminated. For high-throughput bulk transfer, the for high-throughput bulk transfer, the answer is multi-connection, not
application uses N independent channels connections — same trade-off as stripping the header.
HTTP/2-over-TLS vs HTTP/3-over-QUIC. This is a transport property, not a - All channels on one in-line connection share one transport stream's
channels-format property. flow-control window. A slow consumer on one channel can backpressure
- 9 bytes per chunk is 4 bytes more than TTY's 5-byte format. For others. Mitigated by bounded-buffer backpressure (ADR-076), not
high-frequency small-chunk workloads (e.g., typing in a terminal), this is eliminated. The native substrate (QUIC streams) avoids this — each
a 80% header overhead increase. In practice the chunk size is driven by channel gets its own flow-control window. For high-throughput, use native
the write pattern (a terminal sends a few bytes per keystroke regardless), or multi-connection.
and the 4-byte delta is noise next to the TLS/QUIC overhead.
## Door type ## Door type
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 + **One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
length:u32`) is a wire-format commitment. Changing field widths, order, or length:u32`) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
semantics after deployments exist requires a version migration. The control, `% 3` formula) are wire-format commitments. Changing them after
`stream_type` assignments (0=stdin, 1=stdout, etc.) are one-way for the same deployments exist requires a version migration.
reason — they are inherited from ADR-052 and preserved.
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
within the one-way format — it can be changed without a wire-format version within the one-way format.
bump as long as both ends agree (it's a validation threshold, not a field
width).
## References ## References
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes) - ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection this amended by ADR-077 — scoped to direct TTY)
format rides on) - ADR-065: `Connection::from_stream` (the transport-agnostic Connection)
- ADR-070: `BidiStreamSource` trait (the extension point the channels - ADR-070: `BidiStreamSource` trait (the extension point the channels
connection implements) connection implements; its docstring already anticipated per-channel
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated the streams)
format (28 tests, WASM compile check) - ADR-072: channel 0 pre-negotiated (now uses stream_types [0, 1])
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format - ADR-073: channel lifecycle operations (stream_types field examples
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation updated)
- ADR-074: ChannelBidiStreamSource (into_sub_streams returns unidirectional
handles)
- ADR-076: backpressure (bounded-buffer applies at reassembly regardless of
substrate)
- ADR-077: TTY inside channels (5 sub-streams; control properly
bidirectional via 3/4)
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated
the format (28 tests, WASM compile check)
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format, §Less
Straightforward Parts (the control-channel bidirectionality problem)
@@ -64,14 +64,21 @@ exactly as it does on a top-level `alknet/call` connection.
### Channel 0's stream_type usage ### Channel 0's stream_type usage
| stream_type | purpose | | stream_type | direction | purpose |
|-------------|---------| |-------------|-----------|---------|
| 0 | `EventEnvelope` frames (JSON, length-prefixed — the call protocol wire format) | | 0 | write (client→server) | `EventEnvelope` frames from the client (call.requested, call.aborted) |
| 1-255 | reserved for future call-protocol sub-streams | | 1 | read (server→client) | `EventEnvelope` frames from the server (call.responded, call.completed, call.error) |
Channel 0 uses only `stream_type` 0. The reservation of `stream_type` Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via
1-255 is a future-proofing detail, not a current commitment — the call two unidirectional halves, the same way every channel type works
protocol is JSON-only and single-stream by design (ADR-064). (ADR-071 §stream_type decomposition). The call protocol's `(SendStream,
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
`RecvStream` backed by stream_type 1. Both sides write to their write half
and read from their read half — no shared stream_type both sides write to.
The call protocol is JSON-only and single-stream by design (ADR-064).
stream_types 2-255 on channel 0 are reserved for future call-protocol
sub-streams.
## Consequences ## Consequences
@@ -50,7 +50,7 @@ Request (`call.requested` on channel 0):
"operation": "channel/open", "operation": "channel/open",
"input": { "input": {
"alpn": "alknet/tty", "alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3], "stream_types": [0, 1, 2, 3, 4],
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" }, "params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder" "direction": "initiator-to-responder"
} }
@@ -60,8 +60,8 @@ Request (`call.requested` on channel 0):
| field | type | meaning | | field | type | meaning |
|-------|------|---------| |-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. The responder looks this up in its `HandlerRegistry`. | | `alpn` | string | The ALPN the channel will carry. The responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. Declared at open time so both sides size reassembly. E.g. `[0,1,2,3]` for TTY, `[0,1]` for a tunnel. | | `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 (call frames). See ADR-071 §stream_type decomposition. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is the `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params` — it hands the JSON to the handler. | | `params` | object | ALPN-specific parameters. For `alknet/tty` this is the `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. | | `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
Response (`call.responded`): Response (`call.responded`):
@@ -70,7 +70,7 @@ Response (`call.responded`):
{ {
"output": { "output": {
"channel_id": 7, "channel_id": 7,
"stream_types": [0, 1, 2, 3] "stream_types": [0, 1, 2, 3, 4]
} }
} }
``` ```
@@ -230,14 +230,20 @@ alternative.
| Control path | When | Examples | | 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 | | 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 | | `stream_type 3` (write, client→server) and `stream_type 4` (read, server→client) chunks on the data channel | 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 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 example of data-ordered control — the exit message rides on `stream_type 4`
must arrive after the last stdin chunk, guaranteed by chunk ordering within (read, server→client) because it must arrive after the last data on
`(channel_id, stream_type)`, not by a call-protocol round-trip. The `stream_type 1`, guaranteed by per-stream_type ordering. The client's EOF
`channel/close` operation that follows is on channel 0 and is ordered after signal rides on `stream_type 3` (write, client→server), ordered after the
the data pump completes (REQ-CH-06). last data on `stream_type 0`. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**Every stream_type is unidirectional** (ADR-071 §stream_type decomposition).
Control is bidirectional via two halves (3 in, 4 out), not one shared
stream_type both sides write to. This resolves the TTY control channel's
"not actually bidirectional" flaw.
## Consequences ## Consequences
@@ -74,21 +74,30 @@ session — exactly as the POC's `EchoHandler` and `TtyAdapter` do today.
### Sub-stream accessor for typed destructure (OQ-CH-10) ### Sub-stream accessor for typed destructure (OQ-CH-10)
Some handlers need access to `stream_type` 2 (stderr) and 3 (control) in Some handlers need access to `stream_type` 2 (stderr), 3 (control in), and
addition to the main 0/1 pair. The `Connection` interface alone (accept_bi) 4 (control out) in addition to the main 0/1 pair. The `Connection`
only exposes the 0/1 pair. The channels crate provides a typed-accessor interface alone (accept_bi) only exposes the 0/1 pair. The channels crate
extension: provides a typed-accessor extension:
```rust ```rust
// In alknet-channels: // In alknet-channels:
pub struct ChannelSubStreams { pub struct ChannelSubStreams {
pub streams: Vec<(u8, SendStream, RecvStream)>, /// (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 { impl ChannelBidiStreamSource {
/// Returns the typed sub-streams for this channel, keyed by stream_type. /// Returns the typed sub-streams for this channel, keyed by stream_type.
/// Consumes the source — call this instead of accept_bi() if the handler /// Consumes the source — call this instead of accept_bi() if the handler
/// needs direct access to stream_types 2/3. For handlers that only need /// needs direct access to stream_types 2/3/4. For handlers that only need
/// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not /// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not
/// called). /// called).
pub fn into_sub_streams(self) -> ChannelSubStreams { ... } pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
@@ -101,15 +110,24 @@ its typed names:
```rust ```rust
// In alknet-tty (inside-channels mode, ADR-077): // In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams(); let sub = channel_source.into_sub_streams();
let mut stdin = sub.get(0); // SendStream let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let mut stdout = sub.get(1); // RecvStream let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let mut stderr = sub.get(2); // RecvStream (optional) let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let mut control = sub.get(3); // RecvStream (JSON control) let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
``` ```
**The channels crate does not know about TTY's `stream_type` semantics.** It **Every stream_type is unidirectional** (ADR-071). Write stream_types
exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate (`% 3 == 0`) carry a `SendStream`; read stream_types (`% 3 == 1 or 2`) carry
maps stream_types to its typed names. This preserves ADR-003's a `RecvStream`. There is no "bidirectional" stream_type — bidirectionality
is two halves (e.g., control is 3 write + 4 read). This resolves the TTY
control channel's "not actually bidirectional" flaw: the TTY adapter reads
exit/keepalive from `ctrl_out` (stream_type 4) and writes resize/signal/eof
to `ctrl_in` (stream_type 3), each with its own flow control and EOF.
**The channels crate does not know about TTY's `stream_type` semantics.**
It 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 no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind. ALPN-blind.
@@ -39,27 +39,37 @@ impl ProtocolHandler for ChannelsAdapter {
async fn handle(&self, connection: Connection, auth: &AuthContext) async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError> -> Result<(), HandlerError>
{ {
// 1. One bidi stream carries all channels. // 1. Channel 0 is pre-negotiated as alknet/call (ADR-072).
// The first bidi stream the transport yields is channel 0.
let (send, recv) = connection.accept_bi().await?; let (send, recv) = connection.accept_bi().await?;
// 2. Channel 0 is pre-negotiated as alknet/call (ADR-072).
// Construct its reassembly buffers, wrap as a Connection via
// from_source(ChannelBidiStreamSource), and hand to the
// CallAdapter (looked up in the registry, same as every ALPN).
self.manager.preinstall_channel_0(send, recv, auth).await?; self.manager.preinstall_channel_0(send, recv, auth).await?;
// 3. Run the demux loop: read 9-byte headers, route payloads to // 2. Accept remaining bidi streams and read 9-byte headers off each.
// per-(channel_id, stream_type) reassembly buffers. // On an in-line transport (TCP+TLS, WebTransport), accept_bi()
self.manager.run_demux_loop(recv).await // yields once and the header demuxes N channels from that stream.
// On QUIC native, accept_bi() yields repeatedly — each stream
// carries one logical channel, and the header provides
// stream_type + channel_id correlation. Same code path, same
// wire format (ADR-071 §substrate modes).
self.manager.run_demux_loop(connection).await
} }
} }
``` ```
The `preinstall_channel_0` step is the only special case: it constructs the The `preinstall_channel_0` step constructs the reassembly buffers for
reassembly buffers for `channel_id = 0`, wraps them as a `Connection` (via `channel_id = 0` using stream_types [0, 1] (ADR-072), wraps them as a
`Connection::from_source` with a `ChannelBidiStreamSource` — ADR-074), and `Connection` (via `Connection::from_source` with a `ChannelBidiStreamSource`
hands that `Connection` to the `CallAdapter` — exactly as if `alknet/call` — ADR-074), and hands that `Connection` to the `CallAdapter` — exactly as if
had been the top-level ALPN. The `CallAdapter` is none the wiser. `alknet/call` had been the top-level ALPN. The `CallAdapter` is looked up in
the same `HandlerRegistry` as every other ALPN.
`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.
### `ChannelManager` — the shared state ### `ChannelManager` — the shared state
@@ -36,8 +36,8 @@ The `TtyAdapter` operates in two modes, determined by how it receives its
| Mode | When | Wire format | How the adapter gets sub-streams | | Mode | When | Wire format | How the adapter gets sub-streams |
|------|------|-------------|---------------------------------| |------|------|-------------|---------------------------------|
| **Direct (`alknet/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alknet/tty` | TTY's 5-byte format (ADR-052) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-3 | | **Direct (`alknet/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alknet/tty` | TTY's 5-byte format (ADR-052, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
| **Inside channels** | `channel/open` with ALPN `alknet/tty` on a channels connection | Channels' 9-byte format (ADR-071) — the channels layer de-chunks | `into_sub_streams()` (ADR-074) → four named `SendStream`/`RecvStream` pairs for stream_types 0-3 | | **Inside channels** | `channel/open` with ALPN `alknet/tty` on a channels connection | Channels' 9-byte format (ADR-071) — the channels layer de-chunks | `into_sub_streams()` (ADR-074) → five named handles for stream_types 0-4 |
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
(ADR-053). The backend allocates a PTY and returns a `TtyHandle`; the (ADR-053). The backend allocates a PTY and returns a `TtyHandle`; the
@@ -45,16 +45,44 @@ adapter pumps data between the handle and the sub-streams. The difference is
only in how the adapter gets the sub-streams — 5-byte chunk parsing (direct) only in how the adapter gets the sub-streams — 5-byte chunk parsing (direct)
vs. `into_sub_streams()` (channels). vs. `into_sub_streams()` (channels).
### The control channel is now properly bidirectional
The phase-0 findings flagged that the TTY control channel "isn't actually
bidirectional… the adapter ignores Exit from the client." The root cause:
`stream_type 3` was "bidirectional" — one stream both sides wrote to, which
is not properly multiplexed.
ADR-071's stream_type decomposition fixes this: **every stream_type is
unidirectional.** Control is now two halves:
| stream_type | direction | purpose |
|-------------|-----------|---------|
| 3 | write (client→server) | control in: resize, signal, eof |
| 4 | read (server→client) | control out: exit, keepalive response |
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3) and
reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its own
reassembly buffer, its own flow control, its own EOF. The control channel
is *actually* bidirectional — two unidirectional streams, not one shared
stream both sides write to.
This amends ADR-052's stream_type assignments for direct mode too: direct
`alknet/tty` connections now use stream_types [0, 1, 2, 3, 4] (data in/out/
err + control in/out), not [0, 1, 2, 3]. The 5-byte format's `stream_type`
field gains value 4; the `ControlMessage` enum is unchanged (the JSON shape
is the same; the stream_type it rides on splits from 3 into 3+4).
### What changes in alknet-tty ### What changes in alknet-tty
1. **The adapter's session-driving code splits into two entry points:** 1. **The adapter's session-driving code splits into two entry points:**
- `drive_session_direct(send, recv, backends, ...)` — the existing path: - `drive_session_direct(send, recv, backends, ...)` — the existing path:
parse 5-byte chunks, split into stream_types, pump. Used for direct parse 5-byte chunks, split into stream_types 0-4, pump. Used for direct
`alknet/tty` connections. `alknet/tty` connections.
- `drive_session_channels(sub_streams, backends, ...)` — the new path: - `drive_session_channels(sub_streams, backends, ...)` — the new path:
receive `ChannelSubStreams` (four named `SendStream`/`RecvStream` receive `ChannelSubStreams` (five named handles: stdin=SendStream,
pairs), pump directly without chunk parsing. Used when the channel's stdout=RecvStream, stderr=Option<RecvStream>, ctrl_in=SendStream,
`Connection` is backed by `ChannelBidiStreamSource`. ctrl_out=RecvStream), pump directly without chunk parsing. Used when
the channel's `Connection` is backed by `ChannelBidiStreamSource`.
2. **The `TtyAdapter::handle()` branches on the `Connection`'s source type.** 2. **The `TtyAdapter::handle()` branches on the `Connection`'s source type.**
The channels crate's `ChannelBidiStreamSource` is a `BidiStreamSource` The channels crate's `ChannelBidiStreamSource` is a `BidiStreamSource`
@@ -73,20 +101,24 @@ vs. `into_sub_streams()` (channels).
connections." The channels path does not use it. This amends ADR-052's connections." The channels path does not use it. This amends ADR-052's
scope — the format is not replaced, it's scoped. scope — the format is not replaced, it's scoped.
4. **The control channel (stream_type 3) works the same in both modes.** In 4. **The control channel works the same in both modes, now properly
direct mode, control JSON rides in 5-byte chunks with `stream_type=3`. In bidirectional.** In direct mode, control-in JSON rides in 5-byte chunks
channels mode, control JSON rides in 9-byte chunks with `stream_type=3` with `stream_type=3` and control-out rides with `stream_type=4`. In
— but the channels layer de-chunks it, so the adapter reads raw JSON channels mode, control-in rides in 9-byte chunks with `stream_type=3`
bytes from its `control` `RecvStream` in both cases. The (write) and control-out with `stream_type=4` (read) — but the channels
`ControlMessage` enum (resize, signal, eof, exit) is unchanged. layer de-chunks them, so the adapter reads raw JSON bytes from
`ctrl_in`/`ctrl_out` in both cases. The `ControlMessage` enum (resize,
signal, eof, exit) is unchanged — the JSON shape is the same; only the
stream_type assignments change (3 splits into 3+4).
5. **The exit-chunk-is-last invariant (ADR-055) generalizes.** In direct 5. **The exit-chunk-is-last invariant (ADR-055) generalizes.** In direct
mode, the exit chunk is the last 5-byte chunk before stream close mode, the exit chunk is the last 5-byte chunk on `stream_type=4` (read,
(ADR-055). In channels mode, the exit control message is the last data on server→client) before stream close (ADR-055, amended). In channels mode,
`stream_type 3` before `channel/close` is sent on channel 0 the exit control message is the last data on `stream_type=4` before
(ADR-073 §channel/close). The ordering invariant is the same — exit `channel/close` is sent on channel 0 (ADR-073 §channel/close). The
before close — but the mechanism differs: 5-byte chunk ordering (direct) ordering invariant is the same — exit before close — but the mechanism
vs. `stream_type 3` ordering + `channel/close` after pump completion differs: 5-byte chunk ordering on stream_type 4 (direct) vs.
`stream_type 4` ordering + `channel/close` after pump completion
(channels, REQ-CH-06). (channels, REQ-CH-06).
### What does NOT change ### What does NOT change
@@ -0,0 +1,210 @@
# ADR-081: alknet-channels Sub-Crate Decomposition
## Status
Accepted
## Context
The initial channels spec (ADR-071-080) framed `alknet-channels` as a
single crate depending on both `alknet-core` and `alknet-call`. The
dependency on `alknet-call` arises because channel lifecycle operations
(`channel/open`, `channel/close`, `channel/control`,
`channel/resources/subscribe`) register on the call protocol's
`OperationRegistry`, and channel 0 is pre-negotiated as `alknet/call`
(ADR-072).
This creates two issues:
1. **The dependency graph conflates the pure multiplexer with the call-
protocol coupling.** The wire format, demux/mux, and
`ChannelBidiStreamSource` are ALPN-blind and call-protocol-blind — they
depend on `alknet-core` only. The channel 0 pre-negotiation and lifecycle
op registrations are the call-protocol coupling. Baking both into one
crate means any consumer that wants the multiplexer also pulls in the
call-protocol coupling, even if they don't need channel 0 to be
`alknet/call`.
2. **The "no special-casing for downstream crates" principle is
violated.** The user's constraint: "we don't want to be doing anything
special for downstream crates unless it's needed/useful to the point of
they all kind of benefit." The call-protocol coupling is a channels-crate
concern (channels needs call for orchestration), not a downstream-crate
concern. Separating them makes the dependency graph honest: the pure
multiplexer has no opinion about channel 0; the call-protocol coupling
is isolated where it belongs.
The substrate simplification (ADR-071 revised) strengthened this: the wire
format is the same across all substrates, and the `ChannelsAdapter` is
substrate-agnostic. The pure multiplexer (`channels-core`) is cleanly
separable from the call-protocol orchestration (`channels-call`).
## Decision
### Three crates
```
alknet-channels-core — the pure multiplexer (wire format, demux/mux,
│ ChannelBidiStreamSource, ChannelManager). Depends
│ on alknet-core only. ALPN-blind, call-protocol-blind,
│ transport-blind.
├── alknet-channels-call — channel 0 pre-negotiation + lifecycle op
│ │ registrations on the call protocol's
│ │ OperationRegistry. Depends on channels-core +
│ │ alknet-call.
│ ├── alknet-channels-hub — the relay (ADR-079). Depends on
│ │ channels-call. The hub-side role.
│ └── alknet-channels-worker — ChannelClient (ADR-080). Depends on
│ channels-call. The client/worker-side role.
```
### `alknet-channels-core`
The pure multiplexer. Contains:
- The 9-byte chunk wire format (`parse_header` / `write_header` — ADR-071)
- The demux/mux (`Demux`, `MuxHandle`/`MuxRunner` — ADR-075)
- `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-074)
- `ChannelSubStreams` / `SubStreamHandle` (the typed destructure accessor)
- `ChannelManager` (the shared state — channel_id → ChannelState, but
**without** the `call_ops: Arc<OperationRegistry>` field; the manager is
ALPN-blind and call-protocol-blind)
- `ChannelsAdapter` (the `ProtocolHandler` on `alknet/channels` — the
read/demux loop, substrate-agnostic per ADR-071 revised)
Depends on `alknet-core` only. No `alknet-call` dependency. No opinion about
what channel 0 carries — that's the consumer's concern.
The `ChannelsAdapter::handle` in `channels-core` does NOT preinstall channel
0 as `alknet/call`. It runs the demux loop and routes chunks by
`channel_id`. Channel 0 is just another channel; what ALPN it carries is
determined by the consumer (the `channels-call` crate pre-negotiates it as
`alknet/call`; a hypothetical other consumer could pre-negotiate it
differently).
### `alknet-channels-call`
The call-protocol coupling. Contains:
- Channel 0 pre-negotiation as `alknet/call` (ADR-072) — the
`preinstall_channel_0` logic that constructs channel 0's reassembly
buffers with stream_types [0, 1] and hands the `Connection` to the
`CallAdapter`.
- The four lifecycle operations (ADR-073): `channel/open`,
`channel/close`, `channel/control`, `channel/resources/subscribe` —
registered on the call protocol's `OperationRegistry` at assembly time.
- `ChannelOperations` (the registration helper that closes over a
`ChannelManager` clone).
Depends on `channels-core` + `alknet-call`. This is where the
call-protocol coupling lives, isolated from the pure multiplexer.
### `alknet-channels-hub` and `alknet-channels-worker`
The two roles. These may be sub-crates or feature-gated modules within
`channels-call`; the exact packaging is a two-way-door implementation
detail. The contract is:
- **`channels-hub`** (or the hub role): the relay (ADR-079). Translates
`channel/open` on channel 0, byte-forwards data channels with
`channel_id` rewrite. Depends on `channels-call` (for the call-protocol
translation) + the hub's own `CallAdapter` / `from_call` machinery.
- **`channels-worker`** (or the worker/client role): `ChannelClient`
(ADR-080). Dials a transport, establishes the channels connection, runs
the demux/mux, exposes `open_channel(alpn, params) -> Channel`. Depends
on `channels-call` (for channel 0 pre-negotiation) + `alknet-call`'s
`CallClient`-equivalent.
The naming (hub/worker vs server/client) is a two-way-door detail. The
user noted "server/client" is muddy because a hub/worker can act as both
depending on the use case (bidirectionality — ADR-073 §direction
semantics). The roles are "the side that relays" (hub) and "the side that
dials" (worker/client), but both can open channels in either direction.
### What moves where
| Component | Original (ADR-071-080) | Now |
|-----------|------------------------|-----|
| 9-byte wire format | `alknet-channels` | `channels-core` |
| Demux/Mux | `alknet-channels` | `channels-core` |
| `ChannelBidiStreamSource` | `alknet-channels` | `channels-core` |
| `ChannelSubStreams` | `alknet-channels` | `channels-core` |
| `ChannelManager` (without `call_ops`) | `alknet-channels` | `channels-core` |
| `ChannelsAdapter` (demux loop only) | `alknet-channels` | `channels-core` |
| Channel 0 pre-negotiation | `alknet-channels` (ADR-072) | `channels-call` |
| `channel/open`/`close`/`control`/`resources/subscribe` ops | `alknet-channels` (ADR-073) | `channels-call` |
| `ChannelOperations` registration helper | `alknet-channels` | `channels-call` |
| Hub relay (ADR-079) | `alknet-channels` (spec) / `alknet-hub` (impl) | `channels-hub` (or `alknet-hub` — see below) |
| `ChannelClient` (ADR-080) | `alknet-channels` | `channels-worker` |
### Relationship to `alknet-hub`
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
is the hub pattern: peer lifecycle, aggregated env, service discovery. The
channels hub relay (ADR-079) is a channels-specific concern that the hub
crate consumes. The split: `channels-hub` (or the hub role within
`channels-call`) provides the relay logic; `alknet-hub` wires it into the
hub runtime alongside the call-protocol peer management. Whether the relay
lives in a `channels-hub` sub-crate or directly in `alknet-hub` is a
packaging decision — the contract (ADR-079) is the same either way. The
user's preference for decomposing channels into core/hub/worker suggests a
`channels-hub` sub-crate that `alknet-hub` depends on, but this is not
one-way and can be revisited during implementation.
## Consequences
**Positive:**
- The dependency graph is honest: `channels-core` is the pure multiplexer
with no call dependency; the call-protocol coupling is isolated in
`channels-call`. A consumer that wants the multiplexer without the
call-protocol orchestration can depend on `channels-core` only.
- The "no special-casing for downstream crates" principle is preserved:
`channels-core` doesn't know about `alknet-call`, `alknet-tty`, or any
handler crate. The call-protocol coupling is a channels-crate concern,
not a downstream-crate concern.
- The sub-crate split matches the existing pattern (`alknet-tty` +
`alknet-tty-local`, `alknet-docker` + its `tty` feature) — core in one
crate, consumer-specific wiring in another.
- The hub and worker roles are separated, matching the user's
decomposition preference and the bidirectionality of the channels
protocol (both sides can open channels; the roles are about who relays
vs who dials, not about request/response direction).
**Negative:**
- Three crates instead of one. The assembly layer must depend on
`channels-core` + `channels-call` (and optionally `channels-hub` or
`channels-worker`) instead of one `alknet-channels`. This is the cost of
the clean separation; the assembly layer already wires multiple crates,
so this is consistent with the existing pattern.
- The `ChannelsAdapter` in `channels-core` doesn't preinstall channel 0 —
the consumer does. This means `channels-core`'s `ChannelsAdapter::handle`
exposes a hook (callback or trait method) for the consumer to install
channel 0. `channels-call` provides the `preinstall_channel_0`
implementation; a different consumer could provide a different one. This
is a slightly more complex adapter shape than "channel 0 is always call,"
but it's the cost of the clean separation.
## Door type
**One-way (crate structure) + two-way (role packaging).** The three-crate
split (`channels-core` / `channels-call` / roles) is one-way — once
consumers depend on `channels-core` without `channels-call`, re-merging
them is a breaking change. The hub/worker packaging (separate sub-crates
vs feature-gated modules within `channels-call`) is two-way — an
implementation detail that can change without breaking the contract.
## References
- ADR-003: crate decomposition (no-handler-depends-on-another-handler —
preserved; the channels sub-crates depend on core/call, not on handlers)
- ADR-071: channels wire format (revised — substrate simplification; the
wire format is in `channels-core`)
- ADR-072: channel 0 pre-negotiated (moves to `channels-call`)
- ADR-073: channel lifecycle operations (move to `channels-call`)
- ADR-074: ChannelBidiStreamSource (in `channels-core`)
- ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
`channels-core`, call coupling in `channels-call`)
- ADR-079: hub relay (in `channels-hub` or `alknet-hub`)
- ADR-080: ChannelClient (in `channels-worker`)
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
relay's consumer)