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

+7 -6
View File
@@ -30,16 +30,17 @@ protocol work itself.
| ADR | Title | Relevance |
|-----|-------|-----------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; 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 |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, stream_types [0,1]; no special control plane |
| [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 |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 contracts |
| [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 | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 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 |
| [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 |
| [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) |
@@ -32,7 +32,7 @@ Request (on channel 0):
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3]` 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`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
@@ -14,14 +14,14 @@ invariants.
| 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. |
| `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). |
| `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`. 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
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
#[async_trait]
@@ -31,46 +31,47 @@ impl ProtocolHandler for ChannelsAdapter {
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> 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?;
// 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?;
// 3. Run the demux loop.
self.manager.run_demux_loop(recv).await
// 2. Accept remaining bidi streams and read 9-byte headers off each.
// On an in-line transport, accept_bi() yields once and the header
// demuxes N channels from that stream. On QUIC native, accept_bi()
// yields repeatedly — each stream carries one logical channel.
// Same code path, same wire format (ADR-071 §substrate modes).
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 =
0`, wraps them as a `Connection` (via `Connection::from_source` with a
`ChannelBidiStreamSource` — ADR-070/074), and hands that `Connection` to
the `CallAdapter` — exactly as if `alknet/call` had been the top-level
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every
other ALPN. The `CallAdapter` is none the wiser: it calls `accept_bi()`,
gets one bidi stream (the channel-0 reassembled stream), and runs its
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.
`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`
```rust
// In alknet-channels-core:
pub struct ChannelManager {
channels: Mutex<HashMap<u32, ChannelState>>,
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
buffer_cap: usize, // default 1 MiB (ADR-076)
max_channels: usize, // default 256 (ADR-076)
@@ -11,8 +11,13 @@ access paths.
## What
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles
—one per active `stream_type` (declared at `channel/open` time, ADR-073).
Each channel is reassembled into a set of **unidirectional** handles — one
per active `stream_type` (declared at `channel/open` time, ADR-073). Every
stream_type is unidirectional (ADR-071 §stream_type decomposition);
bidirectionality is two stream_types (write + read), not one shared
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`.
These handles are wrapped as a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
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) —
the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SendStream, RecvStream)`
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/
control).
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
control-out).
Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them.
@@ -115,20 +120,24 @@ For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1:
```rust
// In alknet-channels:
// In alknet-channels-core:
pub struct ChannelSubStreams {
/// (stream_type, send_half, recv_half) for each active stream_type.
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)>,
}
impl ChannelSubStreams {
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
pub enum SubStreamHandle {
Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
}
impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3.
/// direct access to stream_types 2/3/4.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
}
```
@@ -138,15 +147,16 @@ The handler crate destructures `ChannelSubStreams` into its typed names:
```rust
// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get(0).unwrap(); // SendStream
let stdout = sub.get(1).unwrap(); // RecvStream
let stderr = sub.get(2); // Option<&RecvStream>
let control = sub.get(3).unwrap(); // RecvStream (JSON control)
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
```
**The channels crate does not know about TTY's `stream_type` semantics.**
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
maps stream_types to its typed names. This preserves ADR-003's
**Every stream_type is unidirectional** (ADR-071). The channels crate
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
@@ -159,7 +169,7 @@ include the 0/1 pair, so `into_sub_streams()` is the superset.
| Handler shape | Path | Examples |
|---------------|------|---------|
| 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
`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
header.
## Stream types
## Stream types — unidirectional, grouped in threes
| stream_type | direction | purpose |
|-------------|-----------|---------|
| 0 | write half | data flowing in (stdin equivalent) |
| 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 |
**Every stream_type is unidirectional.** Bidirectionality is two
stream_types (write + read), not one "bidirectional" stream_type. The
stream_types are grouped in threes:
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
| | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (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
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
channel's lifetime.
| Channel ALPN | Active stream_types |
|--------------|---------------------|
| `alknet/call` (channel 0) | 0 (EventEnvelope frames) |
| `alknet/tty` | 0, 1, 2, 3 (stdin, stdout, stderr, control) |
| `alknet/tunnel` | 0, 1 (data-in, data-out) |
| `alknet/ssh` | 0, 1 (data-in, data-out — SSH multiplexes internally) |
| Channel ALPN | Active stream_types | Why |
|--------------|---------------------|-----|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
## Substrate modes — same wire format, different stream counts
The 9-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the transport
yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
each stream is a channel, and the header provides `stream_type` and
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes, ADR-075.
## 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
routed to the `CallAdapter` without an explicit `channel/open` exchange.
Channel 0 uses only `stream_type` 0 for `EventEnvelope` frames (JSON,
length-prefixed — the call protocol wire format, ADR-064). `stream_type`
1-255 on channel 0 are reserved for future call-protocol sub-streams.
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
(client→server), 1=out (server→client). The call protocol's `(SendStream,
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are
reserved for future call-protocol sub-streams.
Channel 0's chunks have `channel_id = 0` in the header — same format as
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 —
the format is self-synchronizing.
Within a channel, `stream_type` 0 (stdin) from the server is invalid, so
`0x00` as the first byte of a chunk payload from the server is unambiguous
(carried from ADR-052 §5).
Within a channel, `stream_type` 0 (write half) from the server is invalid,
so `0x00` as the first byte of a chunk payload from the server is
unambiguous (carried from ADR-052 §5).
## 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 |
| 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 |
| 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.
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
`channel/close`. For tunnels it is the last data byte before close. The
channels layer's close handler observes the pump completion; the call
operation is issued after.
the exit control message on `stream_type 4` (read, server→client) is the
last data before `channel/close`. For tunnels it is the last data byte
before close. The channels layer's close handler observes the pump
completion; the call operation is issued after.
This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler
+38 -18
View File
@@ -94,29 +94,48 @@ design.
## Crate dependencies
```
alknet-channels
alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
│ make_streaming_handler, CallError, ResponseEnvelope)
├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads)
├── async-trait
├── thiserror
└── 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
operations (`channel/open`, `channel/close`, `channel/control`,
`channel/resources/subscribe`) are registered on the call protocol's
`OperationRegistry` (ADR-073). This is the one handler-crate →
`alknet-call` dependency; it is sound because channels *is* a call-protocol
extension (channel lifecycle is call operations), not a peer handler.
`alknet-channels-core` is the pure multiplexer — wire format, demux/mux,
`ChannelBidiStreamSource`, `ChannelManager`. It depends on `alknet-core`
only. No `alknet-call` dependency. ALPN-blind, call-protocol-blind,
transport-blind. This is where the "streams are streams" insight lives.
The dependency direction is: handlers depend on `alknet-core`;
`alknet-channels` depends on `alknet-core` and `alknet-call`; nothing
depends on `alknet-channels` except the assembly layer and
`alknet-tty`/`alknet-docker` behind their `channels`/`tty` feature gates.
`alknet-channels-call` is the call-protocol coupling — channel 0
pre-negotiation as `alknet/call` (ADR-072) and the four lifecycle operations
(ADR-073) registered on the call protocol's `OperationRegistry`. This is
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
@@ -220,16 +239,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, no special control plane |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
| [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 |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 |
| [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 | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 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 |
| [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) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling) / hub / worker |
## Open Questions