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

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

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

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

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

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

No files matched your search

+60 -49
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-17 last_updated: 2026-07-18
--- ---
# Alknet Architecture # Alknet Architecture
@@ -78,78 +78,87 @@ The [overview.md](overview.md) crate graph and ALPN registry are
rewritten to match. rewritten to match.
**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`, 8-byte chunk format, N
channels over transport stream(s), 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 eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte and twelve ADRs — [ADR-071](decisions/071-channels-wire-format.md) (8-byte
chunk header; revised for substrate simplification — the header is used in chunk header; amended by ADR-093 — the channels layer has no `stream_type`
all substrates including QUIC native, not just in-line; and stream_type concept; the handler owns its sub-stream multiplexing on the `BiStream`),
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) [ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
(channel 0 = `alknet/call` pre-negotiated, stream_types [0,1] — call frames (channel 0 = `alknet/call` pre-negotiated; the call protocol's
bidirectional via 0=in, 1=out), `EventEnvelope` framing is the channels payload, carried transparently),
[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; amended by ADR-093 — `stream_types` field removed from
`stream_type 3`/`4` for data-ordered control), `channel/open`, `stream_type` field removed from `channel/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()` with `SubStreamHandle` enum (Send/Recv extension point; amended by ADR-093 — `into_sub_streams()` removed;
per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH), `accept_bi()` is the only accessor, yields one `BiStream` per channel),
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md) [ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
(`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off (`ChannelsAdapter` substrate-agnostic demux loop (reads 8-byte headers off
every bidi stream, regardless of substrate) + `ChannelManager` every bidi stream, regardless of substrate) + `ChannelManager`
reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned: reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
shutdown emits zero-length sentinel, transport close drops all senders, mux shutdown emits zero-length sentinel, transport close drops all senders, mux
dynamic registration, lenient unknown-`channel_id`), 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 per channel, 256-channel cap,
with wrap-around), monotonic IDs with wrap-around; amended by ADR-093 — per-`channel_id`,
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses not per-`(channel_id, stream_type)`),
sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4] [ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels —
with control properly bidirectional via 3 (write) + 4 (read); ADR-052's **reversed by ADR-093**: TTY always uses its 5-byte format, carried
scope amended to direct-connect TTY only; `channels` feature on alknet-tty), transparently in the channels payload; the two-mode design is preserved
but differs only in `BiStream` source, not in parsing; the control channel
split is TTY-internal, not channels-layer),
[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;
core helper extraction deferred per OQ-57), core helper extraction deferred per OQ-57),
[ADR-079](decisions/079-hub-relay-translate-not-forward.md) (hub relay [ADR-079](decisions/079-hub-relay-translate-not-forward.md) (hub relay
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 (4-byte field
protocol-specific handlers), rewrite within the 8-byte header); the hub never runs protocol-specific
handlers),
[ADR-080](decisions/080-channelclient.md) (`ChannelClient`, [ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
transport-agnostic `from_connection` primary; `connect_quic` removed transport-agnostic `from_connection` primary; `connect_quic` removed
per ADR-089 §5 (dial extracted to `AlknetClient`), per ADR-089 §5 (dial extracted to `AlknetClient`),
bidirectionality preserved; `AlknetClient` dial-seam extracted as bidirectionality preserved; `AlknetClient` dial-seam extracted as
`alknet-client` per ADR-089, resolving OQ-55), `alknet-client` per ADR-089, resolving OQ-55; amended by ADR-093 —
`stream_types` field removed from `open_channel` and `Channel`),
[ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate [ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate
decomposition — `channels-core` (pure multiplexer, depends on alknet-core decomposition — `channels-core` (pure multiplexer, depends on alknet-core
only, no call dependency) / `channels-call` (channel 0 pre-negotiation + only, no call dependency) / `channels-call` (channel 0 pre-negotiation +
lifecycle op registrations, depends on channels-core + alknet-call) / lifecycle op registrations, depends on channels-core + alknet-call) /
`channels-hub` (relay) / `channels-worker` (ChannelClient); isolates the `channels-hub` (relay) / `channels-worker` (ChannelClient); isolates the
call-protocol coupling from the pure multiplexer). The specs are grounded call-protocol coupling from the pure multiplexer; amended by ADR-093 —
in the completed de-risk POC 8-byte wire format, `ChannelSubStreams`/`SubStreamHandle` removed),
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three [ADR-093](decisions/093-channels-pure-channel-multiplexing.md) (the
validated targets: chunk format + demux/mux, per-channel `Connection` umbrella decision: channels layer is pure channel multiplexing — 8-byte
presentation, tunnel handler). The core prerequisite — ADR-070 header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only,
(`BidiStreamSource` trait + `Connection::from_source`) — is landed and TTY always 5-byte; amends ADR-071/074/077 and the channels-facing clauses
implemented. The spec work converted three research hedges into decisions: of ADR-072/073/075/076/080/081). The specs are grounded in the completed
de-risk POC (`docs/research/alknet-channels/poc-summary.md`, 28 tests
passing, three validated targets: chunk format + demux/mux, per-channel
`Connection` presentation, tunnel handler) and the stream-unification
research (`docs/research/stream-unification/findings.md`, which surfaced
the pure-multiplexing resolution). The core prerequisite — ADR-070
(`BidiStreamSource` trait + `Connection::from_source`) + ADR-092
(`BiStream` as the handler leaf) — is landed and implemented. The spec
work converted three research hedges into decisions:
`channel/resources` is subscribe from day one (not poll-for-v1), channel `channel/resources` is subscribe from day one (not poll-for-v1), channel
ID allocation is server-assigned (not "if zero-RTT needed"), and ID allocation is server-assigned (not "if zero-RTT needed"), and
backpressure is bounded-buffer (not "if HOL blocking becomes a problem"). backpressure is bounded-buffer (not "if HOL blocking becomes a problem").
Two genuine deferrals: OQ-56 (full windowing — blocked on a real HOL- Two genuine deferrals: OQ-56 (full windowing — blocked on a real HOL-
blocking observation) and OQ-57 (two-pump helper extraction — blocked on a blocking observation) and OQ-57 (two-pump helper extraction — blocked on
second two-pump handler). The TTY integration (ADR-077) amends ADR-052's a second two-pump handler). ADR-093 is the channels-layer consequence of
scope — the 5-byte format is unchanged for direct `alknet/tty` connections; ADR-092's `BiStream` handler-leaf decision — every channel is a
inside channels, TTY uses `into_sub_streams()` and the channels layer's `BiStream`, the handler owns its sub-stream multiplexing, the channels
de-chunking, with control properly bidirectional via stream_types 3/4. layer has no `stream_type` concept.
**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.
@@ -245,10 +254,10 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` in `alknet-tls` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); isolates cert-reuse from transport wrappers (ADR-082) | | [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` in `alknet-tls` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); isolates cert-reuse from transport wrappers (ADR-082) |
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; `CallClient::connect`/`ChannelClient::connect_quic` removed (dial centralized here); `alknet/register` named (wire protocol deferred, OQ-66) | | [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; `CallClient::connect`/`ChannelClient::connect_quic` removed (dial centralized here); `alknet/register` named (wire protocol deferred, OQ-66) |
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; `EndpointError` removed (vestigial); handler crates no longer transitively link quinn/iroh | | [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; `EndpointError` removed (vestigial); handler crates no longer transitively link quinn/iroh |
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream | | [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 8-byte chunk format, N channels over one transport stream |
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates | | [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) | | [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 8-byte chunk format, the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `into_sub_streams()` typed accessor, recursive composition | | [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `accept_bi` yields `BiStream`, recursive composition |
| [crates/channels/channels-adapter.md](crates/channels/channels-adapter.md) | draft | `ChannelsAdapter`, `ChannelManager`, demux/mux contracts (REQ-CH-01..04), two-pump pattern (ADR-078) | | [crates/channels/channels-adapter.md](crates/channels/channels-adapter.md) | draft | `ChannelsAdapter`, `ChannelManager`, demux/mux contracts (REQ-CH-01..04), two-pump pattern (ADR-078) |
| [crates/channels/channel-operations.md](crates/channels/channel-operations.md) | draft | `channel/open`/`close`/`control`/`resources/subscribe`, ACL flow, `direction` semantics, hub relay contract (ADR-079) | | [crates/channels/channel-operations.md](crates/channels/channel-operations.md) | draft | `channel/open`/`close`/`control`/`resources/subscribe`, ACL flow, `direction` semantics, hub relay contract (ADR-079) |
| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved | | [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
@@ -327,17 +336,17 @@ adapter location map is now consistent: all HTTP-backed adapters
| [068](decisions/068-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations Override | Proposed | | [068](decisions/068-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations Override | Proposed |
| [069](decisions/069-from-call-manual-free-function.md) | from_call Is a Manual Free Function, Not Auto-Wired | Proposed | | [069](decisions/069-from-call-manual-free-function.md) | from_call Is a Manual Free Function, Not Auto-Wired | Proposed |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | Accepted | | [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | Accepted |
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 9-Byte Chunk Header | Accepted | | [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 8-Byte Chunk Header | Accepted (amended by ADR-093 — 8-byte header, no `stream_type`) |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted | | [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted (amended by ADR-093 — channel 0's `stream_types` field removed; the call protocol's framing is the channels payload) |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted | | [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted (amended by ADR-093 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`) |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted | | [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted (amended by ADR-093 — `into_sub_streams()` removed; `accept_bi` yields `BiStream`) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted | | [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted (amended by ADR-093 — 8-byte headers, one reassembly buffer per channel) |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted | | [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`) |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (amends ADR-052 scope — 5-byte format scoped to direct TTY) | | [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently) |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | 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 (amended by ADR-093 — `stream_types` field removed from `open_channel` and `Channel`) |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted | | [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted (amended by ADR-093 — 8-byte wire format; `ChannelSubStreams`/`SubStreamHandle` removed) |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) | | [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external; amended 2026-07-15 — endpoint extracted from `alknet-core` into `alknet-endpoint`; `EndpointError` removed — both variants vestigial, `shutdown()` infallible) | | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external; amended 2026-07-15 — endpoint extracted from `alknet-core` into `alknet-endpoint`; `EndpointError` removed — both variants vestigial, `shutdown()` infallible) |
| [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted | | [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted |
@@ -348,10 +357,12 @@ adapter location map is now consistent: all HTTP-backed adapters
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55; `CallClient::connect` / `ChannelClient::connect_quic` removed; §3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`; `CallCredentials` removed per ADR-091 Am. 2026-07-17; `FingerprintPinVerifier` moved to `alknet-tls`; `ClientError` removed; `alknet-call` sheds TLS deps) | | [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55; `CallClient::connect` / `ChannelClient::connect_quic` removed; §3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`; `CallCredentials` removed per ADR-091 Am. 2026-07-17; `FingerprintPinVerifier` moved to `alknet-tls`; `ClientError` removed; `alknet-call` sheds TLS deps) |
| [090](decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | Accepted (§5 amended 2026-07-16 — OQ-67 resolved: iroh force-relay-only + HTTP-to-SOCKS5 bridge) | | [090](decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | Accepted (§5 amended 2026-07-16 — OQ-67 resolved: iroh force-relay-only + HTTP-to-SOCKS5 bridge) |
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) | | [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi` | Accepted (amends ADR-070's `accept_bi` return type; amends ADR-065's `from_stream`/`from_bidi` constructors; amends ADR-074's `ChannelBidiStreamSource::accept_bi` return type; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor) |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | Accepted (amends ADR-071 — 8-byte header; ADR-074 — `into_sub_streams` removed; reverses ADR-077 — TTY always uses its 5-byte format; amends the channels-facing clauses of ADR-072/073/075/076/080/081) |
## Open Questions ## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (67 OQs across 20 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention). Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (68 OQs across 20 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
## Document Lifecycle ## Document Lifecycle
+42 -27
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# alknet-channels # alknet-channels
@@ -12,16 +12,17 @@ each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
channel 0 and routed through the same `HandlerRegistry` as top-level channel 0 and routed through the same `HandlerRegistry` as top-level
connections. The channels layer is a re-framing proxy — it converts between connections. The channels layer is a re-framing proxy — it converts between
"one transport stream carrying N channels" (the wire) and "N independent "one transport stream carrying N channels" (the wire) and "N independent
`AsyncRead + AsyncWrite` handles" (what handlers see) — and it does no `BiStream` handles" (what handlers see) — and it does no protocol work
protocol work itself. itself. The channels layer has no `stream_type` concept (ADR-093); the
handler owns its sub-stream multiplexing on the `BiStream` it receives.
## Documents ## Documents
| Document | Status | Description | | Document | Status | Description |
|----------|--------|-------------| |----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates | | [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
| [channels-wire.md](channels-wire.md) | draft | The 9-byte chunk format (`[channel_id:u32 be][stream_type:u8][length:u32 be][payload]`), stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) | | [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition | | [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074, as amended by ADR-093), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) | | [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) | | [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved | | [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
@@ -30,20 +31,22 @@ protocol work itself.
| ADR | Title | Relevance | | ADR | Title | Relevance |
|-----|-------|-----------| |-----|-------|-----------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door | | [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-093); substrate-agnostic; one-way door |
| [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 | | [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol; no special control plane |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll | | [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) | | [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts | | [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); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope | | [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); **reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently** |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer | | [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`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) | | [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates | | [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements | | [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-093 builds on |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on | | [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) | | [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses | | [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens | | [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler | | [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
@@ -55,16 +58,18 @@ protocol work itself.
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) | | OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" | | OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) | | OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-093); the *function surface* is not |
## Key Design Principles ## Key Design Principles
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP 1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
connection, a QUIC bidi stream — they're all `AsyncRead + AsyncWrite` connection, a QUIC bidi stream — they're all `BiStream` (a concrete
handles. The differences are only in how they're *opened* (negotiation `AsyncRead + AsyncWrite` newtype, per ADR-092). The differences are only
via `channel/open` on channel 0) and what *multiplexing layer* carries in how they're *opened* (negotiation via `channel/open` on channel 0)
them (the 9-byte chunk format). Once normalized, every channel is an and what *multiplexing layer* carries them (the 8-byte chunk format).
ALPN routed through the same `HandlerRegistry`. See Once normalized, every channel is an ALPN routed through the same
[overview.md](overview.md) and ADR-071. `HandlerRegistry`. See [overview.md](overview.md) and ADR-071 (as
amended by ADR-093).
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control 2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
plane.** The call protocol runs on channel 0 exactly as on a top-level plane.** The call protocol runs on channel 0 exactly as on a top-level
@@ -76,23 +81,31 @@ protocol work itself.
3. **The channels layer is a re-framing proxy, not a protocol engine.** It 3. **The channels layer is a re-framing proxy, not a protocol engine.** It
converts between "one transport stream carrying N channels" (the wire) converts between "one transport stream carrying N channels" (the wire)
and "N independent `AsyncRead + AsyncWrite` handles" (what handlers and "N independent `BiStream` handles" (what handlers see). It does no
see). It does no ALPN-specific parsing, no auth, no transport coupling. ALPN-specific parsing, no auth, no transport coupling, and carries no
This makes it WASM-compatible and transport-agnostic by construction. `stream_type` concept (ADR-093). This makes it WASM-compatible and
See [channels-adapter.md](channels-adapter.md) and ADR-075. transport-agnostic by construction. See [channels-adapter.md](channels-adapter.md)
and ADR-075.
4. **`channel/resources/subscribe` is a `Subscription`, not a polled 4. **The handler owns its sub-stream multiplexing.** The channels layer
yields one `BiStream` per channel; the handler sub-multiplexes it
however it wants (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol). The channels layer carries
the bytes transparently. See [channels-connection.md](channels-connection.md)
and ADR-093.
5. **`channel/resources/subscribe` is a `Subscription`, not a polled
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming` `Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
(ADR-049, implemented and tested). The first consumer (the hub (ADR-049, implemented and tested). The first consumer (the hub
aggregating worker resources) needs live updates. Polling would be built aggregating worker resources) needs live updates. Polling would be built
and immediately reworked. See ADR-073. and immediately reworked. See ADR-073.
5. **Bidirectional open.** Either side can open a channel to the other, 6. **Bidirectional open.** Either side can open a channel to the other,
just like the call protocol's operation overlay. The `direction` field just like the call protocol's operation overlay. The `direction` field
on `channel/open` pins who is the ALPN-server vs ALPN-client. See on `channel/open` pins who is the ALPN-server vs ALPN-client. See
ADR-073 §Direction semantics. ADR-073 §Direction semantics.
6. **Wire-level invariants are contracts, not implementation details.** 7. **Wire-level invariants are contracts, not implementation details.**
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
close ordering) that hang channels silently if underspecified: shutdown close ordering) that hang channels silently if underspecified: shutdown
emits a zero-length sentinel; transport close drops all senders; the mux emits a zero-length sentinel; transport close drops all senders; the mux
@@ -101,11 +114,11 @@ protocol work itself.
`channel/close`. See [channels-wire.md](channels-wire.md) and `channel/close`. See [channels-wire.md](channels-wire.md) and
[channels-adapter.md](channels-adapter.md). [channels-adapter.md](channels-adapter.md).
7. **The hub translates, not transparently forwards.** The hub terminates 8. **The hub translates, not transparently forwards.** The hub terminates
channel 0 on both legs, runs `AccessControl::check`, and re-issues channel 0 on both legs, runs `AccessControl::check`, and re-issues
`channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data `channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data
channels are byte-forwarded with `channel_id` rewrite. This preserves channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
the auth model. See ADR-079. within the 8-byte header). This preserves the auth model. See ADR-079.
## References ## References
@@ -115,6 +128,8 @@ protocol work itself.
tests, three validated targets, REQ-CH-01..06 wire-level invariants tests, three validated targets, REQ-CH-01..06 wire-level invariants
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant) surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
- `docs/research/alknet-channels/poc-plan.md` — the POC plan - `docs/research/alknet-channels/poc-plan.md` — the POC plan
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-channel-multiplexing resolution (ADR-093)
- `/workspace/alknet-channels-poc/` — the POC codebase - `/workspace/alknet-channels-poc/` — the POC codebase
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk - `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
format (the seed of the channels generalization) format (the seed of the channels generalization)
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# channel-client.md — ChannelClient # channel-client.md — ChannelClient
@@ -33,18 +33,18 @@ impl ChannelClient {
/// `Connection` on ALPN `alknet/channels`. This is the /// `Connection` on ALPN `alknet/channels`. This is the
/// transport-agnostic primary constructor: the caller (or a /// transport-agnostic primary constructor: the caller (or a
/// transport-specific dial helper) produces the `Connection` — /// transport-specific dial helper) produces the `Connection` —
/// via `Connection::from_stream`/`from_bidi` (TCP+TLS, /// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
/// WebTransport, SSH `direct-tcpip`), a quinn connection, or any /// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
/// other `AsyncRead + AsyncWrite` source — and this method takes /// AsyncWrite` source — and this method takes over: installs
/// over: installs channel 0 (`alknet/call`), spawns the demux/mux, /// channel 0 (`alknet/call`), spawns the demux/mux, and returns
/// and returns the client. Mirrors the server side's /// the client. Mirrors the server side's transport-agnostic
/// transport-agnostic `ChannelsAdapter::handle(Connection)` and /// `ChannelsAdapter::handle(Connection)` and
/// `CallClient::spawn_dispatch(Connection)`. /// `CallClient::spawn_dispatch(Connection)`.
/// ///
/// This is the one-way-door API surface (ADR-080). It must not be /// This is the one-way-door API surface (ADR-080). It must not be
/// coupled to a transport — the channels protocol is /// coupled to a transport — the channels protocol is
/// transport-agnostic (ADR-071, ADR-065), and the client side is /// transport-agnostic (ADR-071, as amended by ADR-093; ADR-065,
/// half of that protocol. /// ADR-092), and the client side is half of that protocol.
pub async fn from_connection(connection: Connection) pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>; -> Result<Self, ChannelError>;
@@ -75,7 +75,6 @@ impl ChannelClient {
pub async fn open_channel( pub async fn open_channel(
&self, &self,
alpn: &str, alpn: &str,
stream_types: &[u8],
params: Value, params: Value,
direction: ChannelDirection, direction: ChannelDirection,
) -> Result<Channel, ChannelError>; ) -> Result<Channel, ChannelError>;
@@ -99,9 +98,8 @@ pub enum ChannelDirection {
pub struct Channel { pub struct Channel {
pub channel_id: u32, pub channel_id: u32,
pub stream_types: Vec<u8>, /// The channel's BiStream, accessible via the BidiStreamSource
/// The sub-streams, accessible via accept_bi() (ADR-074 generic path) /// (accept_bi — ADR-074 as amended by ADR-093).
/// or into_sub_streams() (ADR-074 typed path).
pub source: ChannelBidiStreamSource, pub source: ChannelBidiStreamSource,
} }
@@ -121,11 +119,17 @@ pub struct ResourceEntry {
} }
``` ```
> **Amendment (ADR-093, 2026-07-18):** the `stream_types` field is
> **removed** from `open_channel`'s signature and from `Channel`. The
> channels layer has no `stream_type` concept (ADR-093) — the handler
> owns its sub-stream multiplexing on the `BiStream` it receives. The
> handler's sub-stream set is implicit in its ALPN's wire format.
## Transport-agnostic by construction ## Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol. The channels `ChannelClient` is the client side of the channels protocol. The channels
protocol is transport-agnostic (ADR-071 substrate modes; protocol is transport-agnostic (ADR-071 substrate modes, as amended by
`Connection::from_stream`/`from_bidi`/`from_source` from ADR-065/070 take ADR-093; `Connection::from_bidi`/`from_source` from ADR-065/070/092 take
any `AsyncRead + AsyncWrite`). The client side must not be welded to a any `AsyncRead + AsyncWrite`). The client side must not be welded to a
transport — that would repeat the server-side welding ADR-065 explicitly transport — that would repeat the server-side welding ADR-065 explicitly
unwound. unwound.
@@ -135,7 +139,7 @@ the one-way-door API surface. It takes a pre-established `Connection` and
takes over channels establishment. The transport is the caller's concern: takes over channels establishment. The transport is the caller's concern:
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`, `Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
`from_stream`, a WebSocket carrying `alknet/channels` (the browser path per `from_bidi`, a WebSocket carrying `alknet/channels` (the browser path per
ADR-044) — all produce a `Connection` that `from_connection` accepts ADR-044) — all produce a `Connection` that `from_connection` accepts
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism. unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
@@ -194,6 +198,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) | | [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | `stream_types` removed from `open_channel` and `Channel`; handler owns sub-stream multiplexing |
## Open Questions ## Open Questions
@@ -206,8 +211,10 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
## References ## References
- ADR-080: ChannelClient (the decision) - ADR-080: ChannelClient (the decision)
- ADR-093: channels pure channel multiplexing (`stream_types` removed)
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`) - ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps) - ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-075: ChannelManager (the shared state `ChannelClient` holds) - ADR-075: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction - OQ-55: AlknetClient / client establishment extraction
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the - `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# channel-operations.md — Channel Lifecycle on the Call Protocol # channel-operations.md — Channel Lifecycle on the Call Protocol
@@ -22,7 +22,6 @@ Request (on channel 0):
"operation": "channel/open", "operation": "channel/open",
"input": { "input": {
"alpn": "alknet/tty", "alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3],
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" }, "params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder" "direction": "initiator-to-responder"
} }
@@ -32,7 +31,6 @@ 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,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. |
@@ -41,8 +39,7 @@ Response:
```json ```json
{ {
"output": { "output": {
"channel_id": 7, "channel_id": 7
"stream_types": [0, 1, 2, 3]
} }
} }
``` ```
@@ -50,13 +47,22 @@ Response:
| field | type | meaning | | field | type | meaning |
|-------|------|---------| |-------|------|---------|
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. | | `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
| `stream_types` | `[u8]` | The negotiated set — the responder may narrow the initiator's requested set. |
**Channel ID allocation: server-assigned (DP-1).** One round-trip before **Channel ID allocation: server-assigned (DP-1).** One round-trip before
data flows — the same round-trip the call protocol makes for every data flows — the same round-trip the call protocol makes for every
operation. All current channel types (TTY, tunnel, SSH) already require a operation. All current channel types (TTY, tunnel, SSH) already require a
negotiation round-trip, so the open round-trip is not additive latency. negotiation round-trip, so the open round-trip is not additive latency.
> **Amendment (ADR-093, 2026-07-18):** the `stream_types` field is **removed**
> from `channel/open`'s input and output. The channels layer has no
> `stream_type` concept (ADR-093) — the handler owns its sub-stream
> multiplexing on the `BiStream` it receives. The handler's sub-stream set
> is implicit in its ALPN's wire format (e.g., TTY's 5-byte format
> declares its own `stream_type` set internally; the channels layer carries
> the bytes transparently). The `channel:stream_type_unavailable` error
> code is removed (the channels layer can't refuse a `stream_type` it
> doesn't know about).
**Error codes** (new `CallError.code` strings, not new framing): **Error codes** (new `CallError.code` strings, not new framing):
| code | meaning | retryable | | code | meaning | retryable |
@@ -66,7 +72,6 @@ negotiation round-trip, so the open round-trip is not additive latency.
| `channel:allocation_failed` | Handler allocate failed | true (often transient) | | `channel:allocation_failed` | Handler allocate failed | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false | | `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false | | `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false |
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
### `channel/close` — tear down a channel ### `channel/close` — tear down a channel
@@ -78,7 +83,7 @@ negotiation round-trip, so the open round-trip is not additive latency.
``` ```
The responder (the side that didn't send the close) drains its reassembled The responder (the side that didn't send the close) drains its reassembled
streams for `channel_id`, signals EOF to the handler, and returns stream for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain `{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list). completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
`reason` is free-form for observability — not semantically required. `reason` is free-form for observability — not semantically required.
@@ -87,9 +92,11 @@ completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
MUST be written and flushed before the `channel/close` operation is sent on MUST be written and flushed before the `channel/close` operation is sent on
channel 0. The side closing must observe the data-channel pump complete channel 0. The side closing must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant (ADR-055) carried forward; for tunnels it is the last data byte invariant (ADR-055) carried forward — the exit control message rides on
before close. This invariant crosses two channels (the data channel and TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
channel 0), so the channels layer owns the ordering guarantee. format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0 ### `channel/control` — out-of-band control on channel 0
@@ -101,7 +108,6 @@ keepalive):
"operation": "channel/control", "operation": "channel/control",
"input": { "input": {
"channel_id": 7, "channel_id": 7,
"stream_type": 3,
"message": { "type": "resize", "cols": 80, "rows": 24 } "message": { "type": "resize", "cols": 80, "rows": 24 }
} }
} }
@@ -111,9 +117,16 @@ The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does `channel_id`. The `message` JSON is ALPN-specific; the channels layer does
not interpret it. not interpret it.
> **Amendment (ADR-093, 2026-07-18):** the `stream_type` field is **removed**
> from `channel/control`'s input. Under ADR-093, the channels layer has no
> `stream_type` concept — the control message is routed to the handler's
> control handle (an ALPN-specific concept the handler owns), not to a
> channels-layer `(channel_id, stream_type)` reassembly buffer. The
> handler decides what to do with the message.
### `channel/resources/subscribe` — live resource discovery ### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-049), not a polled `Query`.** The **This is a `Subscription` operation (ADR-049), not a polled Query.** The
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources) needs tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop. live updates when workers connect/disconnect or containers start/stop.
@@ -192,14 +205,26 @@ collision-prone client-assigned 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 | | Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical 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 — it rides on TTY's `STREAM_CTRL_OUT`
must arrive after the last stdin chunk, guaranteed by chunk ordering within (stream_type 4, inside TTY's 5-byte payload format) because it must arrive
`(channel_id, stream_type)`, not by a call-protocol round-trip. The after the last data on TTY's stdout stream_type, guaranteed by TTY's
`channel/close` operation that follows is on channel 0 and is ordered after per-stream_type chunk ordering within its own 5-byte format, not by a
the data pump completes (REQ-CH-06). call-protocol round-trip. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**The control-message division is handler-internal.** Under ADR-093, the
channels layer has no `stream_type` concept — it carries the handler's
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by `channel_id` only; the handler owns its
sub-stream multiplexing on the `BiStream` it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
## ACL flow (end-to-end) ## ACL flow (end-to-end)
@@ -259,6 +284,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll | | [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` | | [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels | | [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | `stream_types` field removed from `channel/open`; `stream_type` removed from `channel/control`; handler owns sub-stream multiplexing |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses | | [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens | | [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries | | [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# channels-adapter.md — ChannelsAdapter and ChannelManager # channels-adapter.md — ChannelsAdapter and ChannelManager
@@ -8,13 +8,15 @@ last_updated: 2026-07-12
The two internal components of the channels crate: the read/demux half The two internal components of the channels crate: the read/demux half
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`). (`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
ADR-075 is the decision; this doc specifies the contracts and the demux/mux ADR-075 is the decision; this doc specifies the contracts and the demux/mux
invariants. invariants. The channels layer has no `stream_type` concept (ADR-093) —
the demux routes by `channel_id` only, and the reassembly buffer is one
per channel (not per `(channel_id, stream_type)`).
## The split ## The split
| Component | Role | What it knows | | Component | Role | What it knows |
|-----------|------|---------------| |-----------|------|---------------|
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. | | `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes, as amended by ADR-093). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). | | `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
@@ -34,33 +36,35 @@ impl ProtocolHandler for ChannelsAdapter {
// 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream // 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream
// the transport yields is channel 0. The consumer (channels-call) // the transport yields is channel 0. The consumer (channels-call)
// installs the CallAdapter on it. // installs the CallAdapter on it.
let (send, recv) = connection.accept_bi().await?; let bidi = connection.accept_bi().await?;
self.manager.preinstall_channel_0(send, recv, auth).await?; self.manager.preinstall_channel_0(bidi, auth).await?;
// 2. Accept remaining bidi streams and read 9-byte headers off each. // 2. Accept remaining bidi streams and read 8-byte headers off each.
// On an in-line transport, accept_bi() yields once and the header // On an in-line transport, accept_bi() yields once and the header
// demuxes N channels from that stream. On QUIC native, accept_bi() // demuxes N channels from that stream. On QUIC native, accept_bi()
// yields repeatedly — each stream carries one logical channel. // yields repeatedly — each stream carries one logical channel.
// Same code path, same wire format (ADR-071 §substrate modes). // Same code path, same wire format (ADR-071 §substrate modes,
// as amended by ADR-093).
self.manager.run_demux_loop(connection).await self.manager.run_demux_loop(connection).await
} }
} }
``` ```
The `preinstall_channel_0` step (provided by `channels-call`, ADR-081) The `preinstall_channel_0` step (provided by `channels-call`, ADR-081)
constructs the reassembly buffers for `channel_id = 0` using stream_types constructs the reassembly buffer for `channel_id = 0`, wraps it as a
[0, 1] (ADR-072), wraps them as a `Connection` via `Connection::from_source` `Connection` via `Connection::from_source` with a
with a `ChannelBidiStreamSource` (ADR-074), and hands that `Connection` to `ChannelBidiStreamSource` (ADR-074, as amended by ADR-093 — `accept_bi`
the `CallAdapter`. The `ChannelsAdapter` in `channels-core` exposes the yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
hook; `channels-call` provides the implementation. The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
provides the implementation.
`run_demux_loop` continues accepting bidi streams from the transport. For `run_demux_loop` continues accepting bidi streams from the transport. For
each stream, it reads 9-byte headers and routes payloads to the matching each stream, it reads 8-byte headers and routes payloads to the matching
`(channel_id, stream_type)` reassembly buffer. On an in-line transport, `channel_id`'s reassembly buffer. On an in-line transport, there is only
there is only one stream (channel 0 rides inside it via the header); the one stream (channel 0 rides inside it via the header); the header demuxes
header demuxes all channels. On QUIC, each subsequent stream is a new all channels. On QUIC, each subsequent stream is a new channel; the
channel; the header's `channel_id` correlates it. The loop is the same; header's `channel_id` correlates it. The loop is the same; only the
only the transport's stream count differs. transport's stream count differs.
## `ChannelManager` ## `ChannelManager`
@@ -79,9 +83,11 @@ pub struct ChannelManager {
struct ChannelState { struct ChannelState {
alpn: String, alpn: String,
streams: HashMap<u8, ReassemblyBuffer>, /// One reassembly buffer per channel (not per (channel_id, stream_type) —
/// the channels layer has no stream_type concept per ADR-093). Yields
/// a BiStream to the handler.
reassembly: ReassemblyBuffer,
handler_task: JoinHandle<()>, handler_task: JoinHandle<()>,
stream_types: Vec<u8>,
} }
``` ```
@@ -90,13 +96,13 @@ struct ChannelState {
all hold a handle. all hold a handle.
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`, > **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are > `ChannelBidiStreamSource`, and `ChannelClient` are the public API
> the public API surface (contract). `ReassemblyBuffer`, `Demux`, > surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and > `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
> `ChannelOperations` are illustrative internal type names — the channels > illustrative internal type names — the channels crate's implementation
> crate's implementation may name them differently. The contracts are the > may name them differently. The contracts are the invariants
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are > (REQ-CH-01..04, 06) and the public API; the internal names are not
> not contractual. > contractual.
### `ChannelManager` is ALPN-blind and auth-blind ### `ChannelManager` is ALPN-blind and auth-blind
@@ -107,8 +113,9 @@ The `ChannelManager` deliberately does **not** hold:
their crates and register on the same registry. their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON, - **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH frames, or tunnel target strings. It hands `params` JSON to the SSH frames, or tunnel target strings. It hands `params` JSON to the
handler and gets back a handler task; it hands `stream_type 3` JSON to the handler and gets back a handler task. The channels layer carries the
handler's control handle. handler's framing transparently in the payload — it does not interpret
the payload bytes.
- **No auth state.** Auth lives in the `OperationContext` that the call - **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in scopes or ownership — that's `AccessControl::check` in
@@ -116,6 +123,10 @@ The `ChannelManager` deliberately does **not** hold:
- **No transport coupling.** It talks to the transport only through the - **No transport coupling.** It talks to the transport only through the
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of `ChannelsAdapter`'s read loop and the per-channel write pumps, both of
which use `AsyncRead + AsyncWrite`. which use `AsyncRead + AsyncWrite`.
- **No `stream_type` concept.** Per ADR-093, the channels layer routes by
`channel_id` only. There is one reassembly buffer per channel (yielding
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
owns its sub-stream multiplexing on the `BiStream` it receives.
This is what makes the channels layer WASM-compatible and transport-agnostic This is what makes the channels layer WASM-compatible and transport-agnostic
— the `ChannelManager` is pure byte routing with no platform or protocol — the `ChannelManager` is pure byte routing with no platform or protocol
@@ -139,8 +150,8 @@ The `channel/open` handler (ADR-073):
missing. missing.
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1: 3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned). server-assigned).
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated 4. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
`stream_types`. ADR-093) — one reassembly buffer, yielding a `BiStream`.
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`. 5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
Identical to what `TtyAdapter::handle` does today, but on a Identical to what `TtyAdapter::handle` does today, but on a
channels-backed `Connection`. channels-backed `Connection`.
@@ -152,7 +163,7 @@ The `channel/open` handler (ADR-073):
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF ### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees `ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
EOF even without an explicit zero-length sentinel on the wire. Without this, EOF even without an explicit zero-length sentinel on the wire. Without this,
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a `read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
sender that never drops. This is a teardown invariant of the sender that never drops. This is a teardown invariant of the
@@ -160,11 +171,10 @@ sender that never drops. This is a teardown invariant of the
### REQ-CH-04: lenient unknown-`channel_id` handling ### REQ-CH-04: lenient unknown-`channel_id` handling
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with A chunk with an unallocated `channel_id` is dropped with a debug log and
a debug log and an error counter (exposed via `Demux::stats()`), and the an error counter (exposed via `Demux::stats()`), and the demux continues.
demux continues. This matches SSH's behavior and survives transient This matches SSH's behavior and survives transient mis-ordering during
mis-ordering during teardown. Validated by the POC teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
(`demux_unknown_channel_drops_lenient`).
## Mux invariants (REQ-CH-03) ## Mux invariants (REQ-CH-03)
@@ -177,8 +187,8 @@ after the run loop starts.
The mux is split into: The mux is split into:
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) -> - **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
Sender<Bytes>` callable at any time after the runner starts. callable at any time after the runner starts.
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations - **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
and per-channel write pumps. and per-channel write pumps.
@@ -222,19 +232,20 @@ channels connections:
```rust ```rust
// For channel_id=7 on browser side, channel_id=12 on spoke side: // For channel_id=7 on browser side, channel_id=12 on spoke side:
tokio::spawn(async move { tokio::spawn(async move {
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await; let mut b_bidi = browser_mgr.open_channel_stream(7).await;
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await; let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
tokio::join!( tokio::join!(
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite) pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite) pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
); );
}); });
``` ```
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream The relay reads opaque bytes off one `ChannelManager`'s reassembled
and writes them onto the other's write-half, which re-chunks them with the `BiStream` and writes them onto the other's write-half, which re-chunks
other leg's `channel_id`. The relay does not parse the bytes — it doesn't them with the other leg's `channel_id` (a 4-byte rewrite within the
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates 8-byte header). The relay does not parse the payload — it doesn't know if
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
`channel/open` on channel 0 (re-issues on the spoke leg with `channel/open` on channel 0 (re-issues on the spoke leg with
`forwarded_for`); data channels are byte-forwarded with `channel_id` `forwarded_for`); data channels are byte-forwarded with `channel_id`
rewrite. See ADR-079 for the full relay contract. rewrite. See ADR-079 for the full relay contract.
@@ -246,6 +257,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts | | [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs | | [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract | | [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels | | [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
@@ -253,9 +265,14 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
## References ## References
- ADR-075: ChannelsAdapter and ChannelManager (the decision) - ADR-075: ChannelsAdapter and ChannelManager (the decision)
- ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step) - ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`) - ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel) - ADR-074: ChannelBidiStreamSource (what the manager constructs per
channel, as amended by ADR-093)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`) - ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7 - `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock) (REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-multiplexing resolution
@@ -1,37 +1,31 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access # channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
How a reassembled channel is presented to its handler as a `Connection`. How a reassembled channel is presented to its handler as a `Connection`.
ADR-074 is the decision; this doc specifies the API shape and the two ADR-074 (amended by ADR-093) is the decision; this doc specifies the API
access paths. shape — one accessor, one `BiStream` per channel.
## What ## What
Each channel is reassembled into a set of **unidirectional** handles — one Each channel is reassembled into a `BiStream` — a single duplex
per active `stream_type` (declared at `channel/open` time, ADR-073). Every (`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
stream_type is unidirectional (ADR-071 §stream_type decomposition); 8-byte header (`channel_id` + `length`) on read, hands the payload to the
bidirectionality is two stream_types (write + read), not one shared reassembled `BiStream`, and the handler parses its own framing from the
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a payload. The handler sub-multiplexes its `BiStream` however it wants —
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`. TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
runs its own channel protocol.
These handles are wrapped as a `ChannelBidiStreamSource` that implements The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is `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)`. The
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
The handler receives a `Connection` and can either: channel), gets a `BiStream`, and drives its session — identical to how it
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) — works on a top-level QUIC connection.
the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
control-out).
Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them.
## `ChannelBidiStreamSource` ## `ChannelBidiStreamSource`
@@ -39,29 +33,30 @@ handler accesses them.
// In alknet-channels: // In alknet-channels:
pub struct ChannelBidiStreamSource { pub struct ChannelBidiStreamSource {
// The reassembly buffers for this channel's active stream_types, // The reassembly buffer for this channel's payload bytes (one per
// plus the mux handle for writing back onto the transport. // channel_id, not per (channel_id, stream_type) — the channels layer
// Constructed by ChannelManager::build_channel_connection (ADR-075). // has no stream_type concept), plus the mux handle for writing back
// onto the transport. Constructed by ChannelManager::build_channel_connection
// (ADR-075).
... ...
} }
#[async_trait] #[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource { impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self) async fn accept_bi(&self)
-> Result<(SendStream, RecvStream), StreamError> -> Result<BiStream, StreamError>
{ {
// Yields the (stream_type 0, stream_type 1) pair on first call, // Yields the channel's BiStream on first call,
// ConnectionClosed on subsequent calls. Yield-once per channel, // ConnectionClosed on subsequent calls. Yield-once per channel,
// matching the POC's validated shape. // matching the POC's validated shape.
} }
async fn open_bi(&self) async fn open_bi(&self)
-> Result<(SendStream, RecvStream), StreamError> -> Result<BiStream, StreamError>
{ {
// StreamClosed — a single channel cannot open new application // StreamClosed — a single channel cannot open new application
// streams (same as ADR-065's Stream backend). Additional sub-streams // streams (same as ADR-065's Stream backend). The handler owns
// (stream_type 2, 3) are accessed via into_sub_streams(), not // its sub-stream multiplexing on the BiStream it received.
// open_bi().
} }
fn remote_addr(&self) -> Option<SocketAddr> { ... } fn remote_addr(&self) -> Option<SocketAddr> { ... }
@@ -75,19 +70,23 @@ whole channels connection). The `ChannelManager` (ADR-075) constructs one
per channel at `channel/open` time and wraps it in a `Connection` via per channel at `channel/open` time and wraps it in a `Connection` via
`from_source`. `from_source`.
## The generic path: `accept_bi()` ## The single path: `accept_bi()`
For handlers that only need the main data pair (`stream_type` 0 = data-in, Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`stream_type` 1 = data-out): `accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. There is one accessor; the two-accessor design
(`accept_bi` vs `into_sub_streams`) from ADR-074's original shape is
removed by ADR-093.
```rust ```rust
// Tunnel handler — ~15 lines, zero channels-layer awareness // Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext) async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError> -> Result<(), HandlerError>
{ {
let (mut send, mut recv) = connection.accept_bi().await?; let mut bidi = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?; let mut tcp = TcpStream::connect(target).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split(); let (mut tcp_read, mut tcp_write) = tcp.into_split();
let (mut recv, mut send) = tokio::io::split(&mut bidi);
// Two-pump with shutdown-on-completion (ADR-078) // Two-pump with shutdown-on-completion (ADR-078)
let c2t = async { let c2t = async {
@@ -105,98 +104,53 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
} }
``` ```
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
pair, and pumps. It does not know it's inside a channels connection — the
`Connection` looks like any other. This is the path the POC's `EchoHandler`
and `TunnelHandler` validated.
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
calls return `ConnectionClosed`. This matches the POC's validated shape and
the `StreamBidiStreamSource` yield-once contract (ADR-070).
## The typed path: `into_sub_streams()`
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1:
```rust ```rust
// In alknet-channels-core: // TTY handler (inside-channels mode, ADR-077 reversed by ADR-093) —
pub struct ChannelSubStreams { // the SAME code as direct mode, just a different BiStream source.
/// (stream_type, handle) for each active stream_type. Each handle is async fn handle(&self, connection: Connection, _auth: &AuthContext)
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream; -> Result<(), HandlerError>
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream. {
/// See ADR-071 §stream_type decomposition. let mut bidi = connection.accept_bi().await?;
pub streams: Vec<(u8, SubStreamHandle)>, // drive_session reads the 5-byte TTY chunks off `bidi` — the same
} // code as direct mode. The channels layer stripped its 8-byte
// header; TTY's 5-byte format is the payload.
pub enum SubStreamHandle { drive_session(bidi, backends, ownership, identity).await
Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
}
impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3/4.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
} }
``` ```
The handler crate destructures `ChannelSubStreams` into its typed names: The handler calls `accept_bi()` once, gets a `BiStream`, and pumps. It
does not know it's inside a channels connection — the `Connection` looks
like any other. This is the path the POC's `EchoHandler` and
`TunnelHandler` validated.
```rust `accept_bi()` is yield-once: the first call returns the `BiStream`;
// In alknet-tty (inside-channels mode, ADR-077): subsequent calls return `ConnectionClosed`. This matches the POC's
let sub = channel_source.into_sub_streams(); validated shape and the `StreamBidiStreamSource` yield-once contract
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server) (ADR-070, ADR-092).
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
```
**Every stream_type is unidirectional** (ADR-071). The channels crate
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
`into_sub_streams()` consumes the source — a handler can't call both
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
include the 0/1 pair, so `into_sub_streams()` is the superset.
## Choosing the path
| Handler shape | Path | Examples |
|---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
| Needs stderr/control (2/3/4) | `into_sub_streams()` | TTY (stdin/stdout/stderr/ctrl-in/ctrl-out) |
The handler chooses based on its ALPN's `stream_type` set (declared at
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
`Connection` (via `from_source`); handlers that need sub-streams access the
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
(exact ergonomics are an implementation detail for the channels crate; the
contract is that both paths are available and the handler crate chooses).
## Recursive composition ## Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection:: A `ChannelBidiStreamSource` is a `BidiStreamSource`, and
from_source` wraps it. A handler that is itself `alknet/channels` can open a `Connection::from_source` wraps it. A handler that is itself
sub-channels connection on a data channel — `alknet/channels` inside `alknet/channels` can open a sub-channels connection on a data channel —
`alknet/channels`. This is allowed (the `Connection` abstraction permits it) `alknet/channels` inside `alknet/channels`. The outer layer strips its
but not a feature designed for. The primary use case is one level of 8-byte header; the inner layer parses its own 8-byte header from the
multiplexing. Recursive composition is a natural consequence of the payload. Each level is the same shape: `BiStream → accept_bi → N
abstraction, not a goal. BiStreams`. The recursion is unbounded and uniform at every level.
This is a property, not a feature. The primary use case is one level of
multiplexing. But the add/strip composition makes it cleaner than
ADR-071's group framing did — the recursion is the same operation
(strip an 8-byte header) at every level, not a different framing per
level.
## What does NOT change ## What does NOT change
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a - **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is `Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
internal to the channels crate; handlers see a `Connection`. internal to the channels crate; handlers see a `Connection`.
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to - **`BiStream`** (ADR-092) — the leaf type `accept_bi` returns. The
wrap their internal sources. `ChannelBidiStreamSource` constructs them via channels layer yields `BiStream`s; handlers parse them per their ALPN.
the existing `from_stream` constructors, backed by mpsc reassembly
buffers.
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in - **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
the same registry as top-level connections. the same registry as top-level connections.
@@ -206,15 +160,22 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor | | [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed, `accept_bi` is the only accessor) |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements | | [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels | | [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References ## References
- ADR-074: ChannelConnection (the decision) - ADR-074: ChannelConnection (the decision, amended by ADR-093)
- ADR-093: channels pure channel multiplexing (the umbrella decision)
- ADR-070: BidiStreamSource trait - ADR-070: BidiStreamSource trait
- ADR-065: `Connection::from_stream` - ADR-092: `BiStream` as the handler leaf
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`) - ADR-065: `Connection::from_stream` (the yield-once path generalized)
- ADR-077: TTY inside channels (reversed by ADR-093 — TTY always uses
its 5-byte format, carried transparently in the channels payload)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the - `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
yield-once `Connection::from_stream` validation) yield-once `Connection::from_stream` validation)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the single-accessor resolution
+175 -136
View File
@@ -1,147 +1,135 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# channels-wire.md — The 9-Byte Chunk Format # channels-wire.md — The 8-Byte Chunk Format
The wire format for `alknet/channels`: a 9-byte chunk header that The wire format for `alknet/channels`: an 8-byte chunk header that
multiplexes N logical channels, each with up to 256 sub-stream types, over multiplexes N logical channels over a single ordered, reliable
a single ordered, reliable bidirectional transport stream. ADR-071 is the bidirectional transport stream. ADR-071 (amended by ADR-093) is the
decision; this doc specifies the format and the wire-level invariants. decision; this doc specifies the format and the wire-level invariants.
The channels layer has no `stream_type` concept — not in its header, not
in its code, not in its mental model. The handler owns its sub-stream
multiplexing on the `BiStream` the channels layer gives it.
## Chunk header ## Chunk header
``` ```
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes] [channel_id: u32 BE][length: u32 BE][payload bytes]
``` ```
9 bytes of header, followed by `length` bytes of payload. 8 bytes of header, followed by `length` bytes of opaque payload.
| field | offset | width | meaning | | field | offset | width | meaning |
|-------|--------|-------|---------| |-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). | | `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
| `stream_type` | 4 | 1 | The sub-stream within the channel. See "Stream types" below. | | `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
| `length` | 5 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
This is a 4-byte extension of alknet-tty's 5-byte format (ADR-052): the The payload is opaque to the channels layer. The handler parses its own
`channel_id` prefix is added; `stream_type` and `length` are identical. The framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
`ChunkReader` / `ChunkWriter` pattern, the framing-disambiguation trick, (5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
and the zero-length sentinel convention all carry forward from TTY. framing, ADR-064), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
### How the wire formats compose
The channels 8-byte header and the handler's framing compose by layering:
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The channels layer reads its 8-byte header (`channel_id` + `length`),
reads `length` bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (`stream_type` + `length`) from the payload bytes.
The two length fields are close but not identical: `ch_len = tty_len + 5`.
This is a small amount of waste per chunk (the channels `length` is always
5 bytes more than TTY's `length`), but the trade-off is clean separation
of concerns: the channels layer has no `stream_type` concept — not in
its header, not in its code, not in its mental model. The handler owns
its framing entirely. See ADR-093 for the full cost/benefit analysis.
## `MAX_CHUNK_LEN` ## `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. The header is always 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 8 bytes, so the demux can always resync by reading the next
header. 8-byte header.
## Stream types — unidirectional, grouped in threes
**Every stream_type is unidirectional.** Bidirectionality is two
stream_types (write + read), not one "bidirectional" stream_type. The
stream_types are grouped in threes:
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
| | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
| | 4 | read (server→client) | control out (ALPN-specific format) |
| | 5 | read (server→client) | control err (optional) |
| Future | 6/7/8 | write/read/read | next group, same pattern |
| | ... | | |
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
256 values / 3 = 85 groups. The `u32` channel_id space combined with 85
stream_type groups is effectively unlimited for the intended use cases.
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
own flow control, its own EOF. Control is bidirectional via two halves
(3 in, 4 out), not one shared stream both sides write to. This resolves the
TTY control channel's "not actually bidirectional" flaw (ADR-077).
**Control payload format is ALPN-specific.** The channels layer is blind to
what stream_types 3/4/5 carry — it reassembles bytes and delivers them to
the handler. TTY happens to use JSON for its control channel; another ALPN
might use a binary format. The channels layer does not mandate JSON on
control stream_types, the same way it doesn't mandate a format for data
stream_types.
Not all channels use all sub-streams. The active set is declared at
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
channel's lifetime.
| Channel ALPN | Active stream_types | Why |
|--------------|---------------------|-----|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
## Substrate modes — same wire format, different stream counts
The 9-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the transport
yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
each stream is a channel, and the header provides `stream_type` and
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes, ADR-075.
## Channel 0 — pre-negotiated `alknet/call` ## Channel 0 — pre-negotiated `alknet/call`
Channel 0 is not a special "control plane" with its own framing. It is 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`
routed to the `CallAdapter` without an explicit `channel/open` exchange. is routed to the `CallAdapter` without an explicit `channel/open`
exchange.
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in Channel 0's chunks have `channel_id = 0` in the 8-byte header — same
(client→server), 1=out (server→client). The call protocol's `(SendStream, format as every other channel. The call protocol's `EventEnvelope` JSON
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0, framing (ADR-064) is the payload; the channels layer carries it
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are transparently. Disambiguation between channel 0 and data channels is by
reserved for future call-protocol sub-streams. `channel_id`, not by a special first-byte trick.
Channel 0's chunks have `channel_id = 0` in the header — same format as ## Framing disambiguation
every other channel. Disambiguation between channel 0 and data channels is
by `channel_id`, not by a special first-byte trick.
## Framing disambiguation (from ADR-052 §5) The 8-byte header is always exactly 8 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 8 bytes, parses the header, reads
The 9-byte header is always exactly 9 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 9 bytes, parses the header, reads
`length` bytes of payload, and routes. If a chunk is dropped (e.g., `length` bytes of payload, and routes. If a chunk is dropped (e.g.,
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header — `ChunkTooLarge`), the demux resyncs by reading the next 8-byte header —
the format is self-synchronizing. the format is self-synchronizing.
Within a channel, `stream_type` 0 (write half) from the server is invalid, There is no channels-layer framing-disambiguation trick beyond the fixed
so `0x00` as the first byte of a chunk payload from the server is 8-byte header. The channels layer does not interpret the payload — it
unambiguous (carried from ADR-052 §5). doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
Any framing disambiguation within the payload is the handler's concern
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
which is internal to TTY's 5-byte format).
## Zero-length sentinel = EOF ## Zero-length sentinel = EOF
A zero-length chunk (`length = 0`) is delivered as an empty `Bytes`, which A zero-length chunk (`length = 0`) is delivered as an empty payload,
the reassembled stream interprets as EOF. This is the clean-shutdown signal which the reassembled stream interprets as EOF. This is the clean-shutdown
for a `(channel_id, stream_type)` pair — same convention as TTY (ADR-052 signal for a `channel_id` — the same convention as TTY (ADR-052
§Sentinels). §Sentinels), now at the channels layer (one sentinel per channel, not
per `(channel_id, stream_type)`).
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
EOF. EOF.
## Substrate modes — same wire format, different stream counts
The 8-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the
transport yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 8-byte header → route by `channel_id` → reassemble into a `BiStream`.
On an in-line transport, `accept_bi()` yields once then
`ConnectionClosed` — the header does all the demux. On QUIC, `accept_bi()`
yields repeatedly — each stream is a channel, and the header provides
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes (as amended by ADR-093), ADR-075.
## Wire-level invariants (REQ-CH-01, 02, 04, 05) ## Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
@@ -151,22 +139,23 @@ These are **contracts**, not implementation details — both sides must agree.
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel ### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
send an empty `Bytes` (the EOF sentinel) before dropping the sender on send an empty payload (the EOF sentinel) before dropping the sender on
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the `AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
channel's `stream_type`, and `tokio::io::copy` in the handler never channel, and `tokio::io::copy` in the handler never
completes — the session hangs. completes — the session hangs.
The TTY crate's `pump_session` emits the zero-length stdout sentinel The TTY crate's `pump_session` emits the zero-length stdout sentinel
explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's explicitly via its own 5-byte format's zero-length chunk; the channels
per-channel write pump does NOT forward a sentinel on sender-drop, so the layer's per-channel write pump does NOT forward a sentinel on
send adapter must. Both sides must agree on this convention, or channels sender-drop, so the send adapter must. Both sides must agree on this
hang on clean shutdown. convention, or channels hang on clean shutdown.
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF ### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
The demux loop MUST clear its `channels` map on transport EOF, dropping all The demux loop MUST clear its `channels` map on transport EOF, dropping
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees all `ReassemblyBuffer` senders. Every handler's reassembled `BiStream`
EOF even without an explicit zero-length sentinel arriving on the wire. sees EOF even without an explicit zero-length sentinel arriving on the
wire.
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
waiting for a sender that never drops because the demux task is holding the waiting for a sender that never drops because the demux task is holding the
@@ -174,11 +163,11 @@ map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter ### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
A chunk with an unallocated `channel_id` (or `stream_type` on an allocated A chunk with an unallocated `channel_id` is dropped with a debug log and
channel) is dropped with a debug log and an error counter (exposed via an error counter (exposed via `Demux::stats()`), and the demux continues.
`Demux::stats()`), and the demux continues. This matches SSH's behavior and This matches SSH's behavior and survives transient mis-ordering during
survives transient mis-ordering during teardown (a chunk for a channel that teardown (a chunk for a channel that was just closed may arrive after
was just closed may arrive after the close is processed). the close is processed).
The alternative (strict — close the transport on unknown `channel_id`) is The alternative (strict — close the transport on unknown `channel_id`) is
fragile during teardown and catches bugs at the cost of reliability. The fragile during teardown and catches bugs at the cost of reliability. The
@@ -187,10 +176,10 @@ fragility.
### REQ-CH-05: bounded-buffer backpressure does not deadlock ### REQ-CH-05: bounded-buffer backpressure does not deadlock
Each `(channel_id, stream_type)` has an independent bounded `mpsc` buffer Each `channel_id` has an independent bounded `mpsc` buffer (default 1 MiB
(default 1 MiB — ADR-076). A slow reader on one channel does not block — ADR-076). A slow reader on one channel does not block another channel's
another channel's reads — the demux's per-chunk route awaits the matching reads — the demux's per-chunk route awaits the matching sender without
sender without holding a global lock. holding a global lock.
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
channel writer faster than the TCP echo server consumer, with no deadlock channel writer faster than the TCP echo server consumer, with no deadlock
@@ -204,17 +193,16 @@ The wire format's core is pure byte manipulation:
```rust ```rust
// wire.rs — sync core, no async, no platform deps, WASM-clean // wire.rs — sync core, no async, no platform deps, WASM-clean
const CHUNK_HEADER_LEN: usize = 9; const CHUNK_HEADER_LEN: usize = 8;
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
pub struct ChunkHeader { pub struct ChunkHeader {
pub channel_id: u32, pub channel_id: u32,
pub stream_type: u8,
pub length: u32, pub length: u32,
} }
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... } pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... } pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
``` ```
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md)) The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
@@ -223,13 +211,35 @@ routing. The split keeps the WASM-compatible core separate from the
tokio-dependent shell. The POC validated the sync core compiles under tokio-dependent shell. The POC validated the sync core compiles under
`wasm32-unknown-unknown`. `wasm32-unknown-unknown`.
## The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
The exact API shape of the add/strip pair (built into the read/write path
vs. a standalone utility) is an implementation detail for the channels
crate, tracked as OQ-68. The *contract* — the channels layer strips its
8-byte header on read and the handler parses its own framing from the
payload — is decided; the *function surface* is not.
## Channel lifecycle (summary) ## Channel lifecycle (summary)
| Phase | Mechanism | Reference | | Phase | Mechanism | Reference |
|-------|-----------|-----------| |-------|-----------|-----------|
| 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 a `BiStream` | this doc, [channels-connection.md](channels-connection.md) |
| Control (data-ordered) | `stream_type 3` (write) and `stream_type 4` (read) chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
| 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 |
@@ -240,24 +250,53 @@ The channel's data chunks MUST be written and flushed before the
invariant: the side closing must observe the data-channel pump complete 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
the exit control message on `stream_type 4` (read, server→client) is the forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
last data before `channel/close`. For tunnels it is the last data byte 4, inside TTY's 5-byte payload) is the last data before `channel/close`.
before close. The channels layer's close handler observes the pump For tunnels it is the last data byte before close. The channels layer's
completion; the call operation is issued after. close handler observes the pump 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
concern. concern. The control-message division (data-ordered control vs
out-of-band control) is now entirely handler-internal: TTY's
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` are stream_types in TTY's 5-byte
payload format, not channels-layer concepts.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
## Open Questions
Open questions are tracked in [open-questions.md](../../open-questions.md).
Key questions affecting this doc:
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided; the *function surface* is not.
## References ## References
- ADR-071: channels wire format (the decision) - ADR-071: channels wire format (the decision, amended by ADR-093 — 8-byte
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes; header, no `stream_type`)
amended by ADR-077 — scoped to direct TTY) - ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload)
- ADR-072: channel 0 pre-negotiated - ADR-072: channel 0 pre-negotiated
- ADR-073: channel lifecycle operations - ADR-073: channel lifecycle operations
- ADR-076: backpressure, channel limits, ID reuse - ADR-076: backpressure, channel limits, ID reuse
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues - `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
Surfaced #4-#6 (REQ-CH-01, 02, 04) Surfaced #4-#6 (REQ-CH-01, 02, 04)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the 8-byte format decision
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation - `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
(carried transparently in the channels payload)
+67 -30
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-18
--- ---
# alknet-channels — Overview # alknet-channels — Overview
@@ -9,17 +9,20 @@ last_updated: 2026-07-12
`alknet-channels` is a multiplexing proxy crate. It implements `alknet-channels` is a multiplexing proxy crate. It implements
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one `ProtocolHandler` for the `alknet/channels` ALPN: it receives one
bidirectional transport stream, reads 9-byte chunk headers, and routes each bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled chunk's payload to the right logical channel. Each channel is reassembled
into an `AsyncRead + AsyncWrite` pair and presented to its handler as a into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
`Connection` — the handler doesn't know it's inside a channels connection. ADR-092) and presented to its handler as a `Connection` — the handler
doesn't know it's inside a channels connection.
Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel
is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed
through the same `HandlerRegistry` as top-level connections. The channels through the same `HandlerRegistry` as top-level connections. The channels
layer does no protocol work itself — it is a re-framing proxy that converts layer does no protocol work itself — it is a re-framing proxy that converts
between "one transport stream carrying N channels" (the wire) and "N between "one transport stream carrying N channels" (the wire) and "N
independent stream handles" (what handlers see). independent `BiStream` handles" (what handlers see). The channels layer has
no `stream_type` concept (ADR-093) — the handler owns its sub-stream
multiplexing on the `BiStream` it receives.
## Why ## Why
@@ -70,12 +73,35 @@ The collapse is at three levels:
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle `AccessControl`, and `forwarded_for` machinery govern channel lifecycle
with no new auth. with no new auth.
### The separation: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed by
`channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them (ADR-093).
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-092). The handler sub-multiplexes it however it
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
bytes, SSH's channel protocol.
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
header, not in its code, not in its mental model. `stream_type` is the
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload.
## Architecture ## Architecture
The crate has two internal components (ADR-075): The crate has two internal components (ADR-075):
- **`ChannelsAdapter`** — implements `ProtocolHandler` for - **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection`, reads 9-byte `alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte
chunk headers, and routes chunks to the `ChannelManager`. The read/demux chunk headers, and routes chunks to the `ChannelManager`. The read/demux
half. half.
- **`ChannelManager`** — the shared state. Holds `channel_id → - **`ChannelManager`** — the shared state. Holds `channel_id →
@@ -84,9 +110,10 @@ The crate has two internal components (ADR-075):
`channel/open` operation handler closes over. `channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074). The `Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074, as
handler calls `accept_bi()` once (yield-once per channel) and drives its amended by ADR-093). The handler calls `accept_bi()` once (yield-once per
session — identical to how it works on a top-level QUIC connection. channel) and gets a `BiStream` — identical to how it works on a top-level
QUIC connection.
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
design. design.
@@ -96,7 +123,7 @@ design.
``` ```
alknet-channels-core alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry, ├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, SendStream, RecvStream, AuthContext) │ BidiStreamSource, BiStream, AuthContext)
├── tokio (spawn, mpsc, io) ├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads) ├── bytes (Bytes for chunk payloads)
├── async-trait ├── async-trait
@@ -159,7 +186,7 @@ stream:
The same wire format, the same chunk reassembly, the same `Connection` The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint. abstraction. The transport is a parameter, not a design constraint.
`Connection::from_stream` / `from_source` (ADR-065/070) handles the `Connection::from_bidi` / `from_source` (ADR-065/070/092) handles the
transport-agnostic `Connection` construction. transport-agnostic `Connection` construction.
## WASM compatibility ## WASM compatibility
@@ -189,7 +216,9 @@ not an architecture concern. The sync core's WASM compatibility is validated.
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
runs on channel 0 exactly as on a top-level `alknet/call` connection. The runs on channel 0 exactly as on a top-level `alknet/call` connection. The
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly `CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
and dispatches operations — it doesn't know it's inside channels. and dispatches operations — it doesn't know it's inside channels. The call
protocol's `EventEnvelope` framing (ADR-064) is the channels payload; the
channels layer carries it transparently.
What changes: the call protocol gains a new class of operations — channel What changes: the call protocol gains a new class of operations — channel
lifecycle (ADR-073). These are registered on the `OperationRegistry` at lifecycle (ADR-073). These are registered on the `OperationRegistry` at
@@ -198,24 +227,27 @@ assembly time and dispatched through the existing `OperationContext` /
### alknet-tty ### alknet-tty
The TTY crate gains a `channels` feature (ADR-077) that enables The TTY crate gains a `channels` feature that enables inside-channels
inside-channels mode. In direct mode (`alknet/tty` ALPN on a top-level mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and
connection), the TTY adapter uses its own 5-byte wire format (ADR-052, inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY
unchanged). In channels mode (`channel/open` with ALPN `alknet/tty`), the adapter uses its own 5-byte wire format (ADR-052). The two modes differ
adapter receives `ChannelSubStreams` (ADR-074) — four named only in *where the `BiStream` comes from* — a top-level connection vs a
`SendStream`/`RecvStream` pairs for stream_types 0-3 — and pumps without channels-backed `Connection`. The same `wire.rs` code runs in both modes
chunk parsing. The `TtyBackend` trait and `TtyHandle` are unchanged; (ADR-077, reversed by ADR-093): the channels layer strips its 8-byte
header and hands TTY the payload bytes; TTY parses its 5-byte header from
the payload. The `TtyBackend` trait and `TtyHandle` are unchanged;
backends don't know which mode the adapter is in. backends don't know which mode the adapter is in.
### alknet-ssh (future) ### alknet-ssh (future)
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
protocol over stream_types 0 and 1. The channels layer hands the protocol on its `BiStream`. The channels layer hands the reassembled
reassembled stream to `SshAdapter`, which feeds it to russh. SSH as a `BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels
channels transport: an SSH `direct-tcpip` channel could carry a channels transport: an SSH `direct-tcpip` channel could carry a channels connection
connection (channels-over-SSH). The SSH crate doesn't need to know about (channels-over-SSH). The SSH crate doesn't need to know about channels —
channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`. `Connection`. SSH multiplexes internally (its own channel protocol rides
the channels payload transparently).
### alknet-docker ### alknet-docker
@@ -240,16 +272,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; unidirectional stream_types in groups of 3; one-way door | | [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] | | [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned | | [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum | | [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 | | [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); 5 sub-streams; control bidirectional via 3/4 | | [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); **reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently** |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level | | [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; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) | | [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers | | [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
## Open Questions ## Open Questions
@@ -268,3 +301,7 @@ Key questions affecting this crate:
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core — - **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing. two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-093); the *function surface* is not.
+2 -1
View File
@@ -439,7 +439,8 @@ closed (no CA to fall back to — ADR-034 §3, Assumption 1).
### Non-Rust native clients (out of scope) ### Non-Rust native clients (out of scope)
The wire protocols (channels 9-byte chunk format — ADR-071; call The wire protocols (channels 8-byte chunk format — ADR-071, as amended
by ADR-093; call
`EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint `EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint
uses X.509 (the web endpoint type, or a native endpoint with X.509 uses X.509 (the web endpoint type, or a native endpoint with X.509
instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python, instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python,
+6 -1
View File
@@ -814,11 +814,16 @@ See [open-questions.md](../../open-questions.md) for full details.
ALPN would serve the same role over QUIC/TCP without HTTP. ALPN would serve the same role over QUIC/TCP without HTTP.
- **OQ-65** (open): WebSocket carrying channels — whether the browser - **OQ-65** (open): WebSocket carrying channels — whether the browser
path extends from call-protocol-only (ADR-048) to full channels path extends from call-protocol-only (ADR-048) to full channels
(the 9-byte chunk format over WebSocket binary frames). If chosen, (the 8-byte chunk format over WebSocket binary frames). If chosen,
the browser is a first-class channels participant and the hub relay the browser is a first-class channels participant and the hub relay
works unchanged for browser legs. The web endpoint advertises works unchanged for browser legs. The web endpoint advertises
`alknet/channels` by default (ADR-086 §3 — the advertisement is `alknet/channels` by default (ADR-086 §3 — the advertisement is
settled; OQ-65 governs whether the browser path uses it). settled; OQ-65 governs whether the browser path uses it).
- **OQ-68** (open): Channels add/strip API shape — whether the 8-byte
header add/strip is built into the channels read/write path or
exposed as a standalone utility. The *contract* is decided (ADR-093);
the *function surface* is not. Does not block the hub (the hub uses
the `ChannelManager` interface either way).
- **OQ-52** (open): `CallConnection::wait_for_close()` — the - **OQ-52** (open): `CallConnection::wait_for_close()` — the
supervision loop needs a way to await connection close. The supervision loop needs a way to await connection close. The
committed interim is polling `connection().accept_bi()` until committed interim is polling `connection().accept_bi()` until
@@ -280,7 +280,11 @@ the args are optional for their transport.
- ADR-002: ProtocolHandler trait (unchanged by this ADR) - ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-007: BiStream type definition (amended by ADR-065; this ADR does not - ADR-007: BiStream type definition (amended by ADR-065; this ADR does not
touch `BiStream`) touch `BiStream`; amended by ADR-092 — `BiStream` is the concrete handler
leaf, not a bare trait)
- ADR-092: `BiStream` as the handler leaf (amends this ADR's `accept_bi`
return type — `(SendStream, RecvStream)` → `BiStream`; the trait shape
and the `from_source` extension point are preserved)
- ADR-009: One-way door decision framework (why `ProtocolHandler` is not - ADR-009: One-way door decision framework (why `ProtocolHandler` is not
changed — this ADR is additive to `Connection`, not a trait revision) changed — this ADR is additive to `Connection`, not a trait revision)
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s - ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
@@ -1,9 +1,39 @@
# ADR-071: alknet-channels Wire Format — 9-Byte Chunk Header # ADR-071: alknet-channels Wire Format — 8-Byte Chunk Header
## Status ## Status
Accepted (revised 2026-07-12: substrate simplification + stream_type Accepted (revised 2026-07-12: substrate simplification + stream_type
decomposition) decomposition; **amended 2026-07-18 by ADR-093: wire format is 8 bytes,
not 9; `stream_type` removed from the channels header — see "Amendment
(ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
The 9-byte chunk header is **amended to 8 bytes**:
`[channel_id:u32 BE][length:u32 BE][payload]`. The `stream_type` byte is
**removed** from the channels header — the channels layer has no
`stream_type` concept, not in its header, not in its code, not in its
mental model. The handler owns its sub-stream multiplexing on the
`BiStream` the channels layer gives it (per ADR-093, the channels-layer
consequence of ADR-092's `BiStream` handler leaf). What was the channels
header's `stream_type` byte is now the first byte of the payload, owned
by the handler's framing (TTY's 5-byte format, call's length-prefixed
JSON, tunnel's raw bytes, SSH's channel protocol).
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
groups) is **removed from the channels layer**. The stream_type concept
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
channels layer carries transparently in its payload. The total header
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
9; the two length fields are close but not identical
(`ch_len = tty_len + 5`). This is the documented cost of clean
separation of concerns — see ADR-093 §"Consequences" for the full
cost/benefit.
The body below describes the **current** (9-byte) shape; the amendment
above is the operative decision. The 9-byte description is kept as the
historical context for the amendment. See ADR-093 for the resolution
rationale and the cross-ADR impacts.
## Context ## Context
@@ -239,17 +269,29 @@ tokio-dependent shell.
## 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`) and the stream_type group assignments (0/1/2 = data, 3/4/5 = length:u32`, 9 bytes) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
control, `% 3` formula) are wire-format commitments. Changing them after control, `% 3` formula) are wire-format commitments. Changing them after
deployments exist requires a version migration. deployments exist requires a version migration.
**Amended by ADR-093 (2026-07-18):** the header layout is now
`channel_id:u32 + length:u32` (8 bytes); the `stream_type` byte and its
decomposition are removed from the channels layer. The one-way door is
re-cast (the channels crate is not yet implemented, so this is the right
time to cast it). See ADR-093 for the amended door-type discussion.
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. within the one-way format.
## References ## References
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
wire format is 8 bytes, not 9; `stream_type` removed from the channels
header; the stream_type decomposition is removed from the channels
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes; - ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
amended by ADR-077 — scoped to direct TTY) amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-093 —
TTY always uses its 5-byte format, carried transparently in the
channels payload**)
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection) - 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; its docstring already anticipated per-channel connection implements; its docstring already anticipated per-channel
@@ -2,7 +2,27 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 by ADR-093 — channel 0's `stream_types`
field is removed; the channels layer has no `stream_type` concept; the
call protocol's `EventEnvelope` framing is the channels payload, carried
transparently — see "Amendment (ADR-093, 2026-07-18)" below)
## Amendment (ADR-093, 2026-07-18)
Channel 0's `stream_types` field (the `[0, 1]` active set) is **removed**.
The channels layer has no `stream_type` concept (ADR-093) — it carries the
call protocol's `EventEnvelope` framing (ADR-064) transparently in the
8-byte header's payload. The call protocol's bidirectionality (client
writes requests, server writes responses) is a call-protocol concern,
not a channels-layer concern; the channels layer routes by `channel_id`
only and yields a `BiStream` to the `CallAdapter`. The `CallAdapter`'s
`accept_bi()` returns one `BiStream` (per ADR-092); the call protocol
reads/writes `EventEnvelope` frames on it, exactly as on a top-level
`alknet/call` connection.
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context ## Context
@@ -121,7 +141,11 @@ unused; assigning them is additive).
## References ## References
- ADR-071: channels wire format (the 9-byte chunk header channel 0 uses) - ADR-071: channels wire format (the 8-byte chunk header channel 0 uses,
as amended by ADR-093)
- ADR-093: channels pure channel multiplexing (amends this ADR —
channel 0's `stream_types` field removed; the call protocol's framing
is the channels payload, carried transparently)
- ADR-073: channel lifecycle operations (registered on channel 0's - ADR-073: channel lifecycle operations (registered on channel 0's
`OperationRegistry`) `OperationRegistry`)
- ADR-064: irpc never integrated — hand-rolled EventEnvelope framing (the - ADR-064: irpc never integrated — hand-rolled EventEnvelope framing (the
@@ -2,7 +2,26 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 by ADR-093 — `stream_types` field removed
from `channel/open`; `stream_type` field removed from `channel/control`;
`channel:stream_type_unavailable` error code removed; the channels layer
has no `stream_type` concept — see "Amendment (ADR-093, 2026-07-18)"
below)
## Amendment (ADR-093, 2026-07-18)
The `stream_types` field is **removed** from `channel/open`'s input and
output. The `stream_type` field is **removed** from `channel/control`'s
input. The `channel:stream_type_unavailable` error code is **removed**.
The channels layer has no `stream_type` concept (ADR-093) — the handler
owns its sub-stream multiplexing on the `BiStream` it receives. The
handler's sub-stream set is implicit in its ALPN's wire format (e.g.,
TTY's 5-byte format declares its own `stream_type` set internally; the
channels layer carries the bytes transparently).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context ## Context
@@ -292,7 +311,11 @@ the underlying one-way commitment.
## References ## References
- ADR-071: channels wire format - ADR-071: channels wire format (amended by ADR-093 — 8-byte header, no
`stream_type`)
- ADR-093: channels pure channel multiplexing (amends this ADR —
`stream_types` field removed from `channel/open`; `stream_type` field
removed from `channel/control`; handler owns sub-stream multiplexing)
- ADR-072: channel 0 is pre-negotiated `alknet/call` - ADR-072: channel 0 is pre-negotiated `alknet/call`
- ADR-049: StreamingHandler for subscriptions (the machinery - ADR-049: StreamingHandler for subscriptions (the machinery
`channel/resources/subscribe` uses — implemented and tested) `channel/resources/subscribe` uses — implemented and tested)
@@ -2,7 +2,29 @@
## Status ## Status
Accepted Accepted (**amended 2026-07-18 by ADR-093: `into_sub_streams()` removed;
`accept_bi` is the only accessor, yields one `BiStream` per channel —
see "Amendment (ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
`into_sub_streams()`, `ChannelSubStreams`, and `SubStreamHandle` are
**removed**. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-092, already landed).
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. The "typed handler path" (this ADR's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
handlers) collapses to one accessor.
The body below describes the **original** (two-accessor) shape; the
amendment above is the operative decision. The two-accessor description
is kept as the historical context for the amendment. See ADR-093 for
the resolution rationale (the channels layer has no `stream_type`
concept; the handler owns its sub-stream multiplexing) and the
cross-ADR impacts.
## Context ## Context
@@ -194,6 +216,11 @@ rewrite of those handlers' integration code. The trait impl is in the
channels crate (not core), so the one-way door is the channels crate's API, channels crate (not core), so the one-way door is the channels crate's API,
not a core type. not a core type.
**Amended by ADR-093 (2026-07-18):** `into_sub_streams()` is removed;
`accept_bi` is the only accessor. The one-way door is re-cast (the
channels crate is not yet implemented, so this is the right time). See
ADR-093 for the amended door-type discussion.
The choice of `into_sub_streams()` returning `Vec<(u8, SendStream, The choice of `into_sub_streams()` returning `Vec<(u8, SendStream,
RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation
detail — the return type can change without breaking the contract as long detail — the return type can change without breaking the contract as long
@@ -201,6 +228,12 @@ as the handler crate's destructure code updates.
## References ## References
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
`into_sub_streams()` removed; `accept_bi` is the only accessor, yields
one `BiStream` per channel; the handler owns its sub-stream
multiplexing)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision
this ADR's amendment builds on — `accept_bi` returns `BiStream`)
- ADR-070: BidiStreamSource trait (the extension point this implements) - ADR-070: BidiStreamSource trait (the extension point this implements)
- ADR-065: Connection::from_stream (the yield-once path this generalizes for - ADR-065: Connection::from_stream (the yield-once path this generalizes for
channels) channels)
@@ -2,7 +2,26 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 by ADR-093 — demux reads 8-byte headers, not
9-byte; one reassembly buffer per `channel_id` (not per
`(channel_id, stream_type)`); `ChannelState.stream_types` removed; the
channels layer has no `stream_type` concept — see "Amendment (ADR-093,
2026-07-18)" below)
## Amendment (ADR-093, 2026-07-18)
The demux loop reads **8-byte headers** (not 9-byte). `ChannelState` has
**one reassembly buffer per `channel_id`** (not per
`(channel_id, stream_type)`), yielding a `BiStream` to the handler. The
`stream_types: Vec<u8>` field on `ChannelState` is **removed**. The
`ChannelManager` has no `stream_type` concept — it routes by `channel_id`
only, and the handler owns its sub-stream multiplexing on the `BiStream`
it receives (per ADR-093, the channels-layer consequence of ADR-092's
`BiStream` handler leaf).
The body below describes the **original** (9-byte, per-stream_type) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context ## Context
@@ -224,11 +243,14 @@ contract.
## References ## References
- ADR-071: channels wire format (the chunks the demux reads) - ADR-071: channels wire format (the chunks the demux reads, as amended
by ADR-093 — 8-byte header)
- ADR-093: channels pure channel multiplexing (amends this ADR — 8-byte
header, one reassembly buffer per channel, no `stream_type` concept)
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step) - ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`) - ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (the per-channel source the manager - ADR-074: ChannelBidiStreamSource (the per-channel source the manager
constructs) constructs, as amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-076: backpressure, channel limits, ID reuse (the `buffer_cap` / - ADR-076: backpressure, channel limits, ID reuse (the `buffer_cap` /
`max_channels` / reuse invariants) `max_channels` / reuse invariants)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#6 - `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#6
@@ -2,7 +2,26 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
2026-07-18)" below)
## Amendment (ADR-093, 2026-07-18)
The bounded-buffer backpressure is per-`channel_id` (not per
`(channel_id, stream_type)`). The channels layer has one reassembly
buffer per channel (yielding a `BiStream`), not one per
`(channel_id, stream_type)`. The 1 MiB default and the 256-channel cap are
unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a
TTY channel with 5 active stream_types under the per-stream_type model).
This is a net improvement (lower memory ceiling per channel), not a
regression. The bounded-buffer *approach* is unchanged; only the
buffer granularity changes (per-channel, not per-stream_type).
The body below describes the **original** (per-stream_type) shape; the
amendment above is the operative decision. See ADR-093 for the resolution
rationale.
## Context ## Context
@@ -134,7 +153,10 @@ doesn't change the wire format, so even that reversal is feasible.
## References ## References
- ADR-071: channels wire format (the chunks the buffers hold) - ADR-071: channels wire format (the chunks the buffers hold, as amended
by ADR-093)
- ADR-093: channels pure channel multiplexing (amends this ADR —
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error) - ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields) - ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure - `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
@@ -2,7 +2,40 @@
## Status ## Status
Accepted Accepted (**reversed 2026-07-18 by ADR-093: TTY always uses its 5-byte
format; the channels layer carries it transparently in the payload —
see "Reversal (ADR-093, 2026-07-18)" below**)
## Reversal (ADR-093, 2026-07-18)
The two-mode TTY design (direct vs inside-channels, with different
sub-stream access paths) is **reversed**. TTY's 5-byte format
(`[stream_type:u8][length:u32][payload]`, ADR-052) is TTY's internal
format, used in **both** direct mode and inside-channels mode. The two
modes differ only in *where the `BiStream` comes from* (a top-level
`alknet/tty` connection vs a `channel/open` with ALPN `alknet/tty`), not
in *how TTY parses it*. The same `wire.rs` code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
(ADR-093) and hands TTY the payload bytes. TTY parses its 5-byte header
from the payload. The channels layer carries TTY's 5-byte chunks
transparently in its payload — no shared fields, no leaked abstraction,
no double-chunking concern (the 13-byte total header is 8 channels + 5
TTY, not 8 + 9; the channels `length` is always `tty_len + 5`).
The `channels` feature on `alknet-tty` becomes "run TTY's sub-demux on a
channels-backed `BiStream`" — the same code as direct mode, different
`BiStream` source. The control channel split (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't
know about it. ADR-074's `into_sub_streams()` (the accessor this ADR's
two-mode design relied on) is removed by ADR-093; TTY sub-demuxes its
`BiStream` via its own 5-byte format instead.
The body below describes the **original** (two-mode) shape; the reversal
above is the operative decision. The two-mode description is kept as
the historical context for the reversal. See ADR-093 for the resolution
rationale (the channels layer has no `stream_type` concept; the handler
owns its sub-stream multiplexing) and the cross-ADR impacts.
## Context ## Context
@@ -178,16 +211,32 @@ re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The `channels` feature gate is two-way — it handling, which is a rewrite. The `channels` feature gate is two-way — it
can be removed if channels integration is no longer needed. can be removed if channels integration is no longer needed.
**Reversed by ADR-093 (2026-07-18):** the two-mode design is reversed —
TTY always uses its 5-byte format, carried transparently in the channels
payload. The one-way door is re-cast (the channels crate is not yet
implemented, so this is the right time). See ADR-093 for the amended
door-type discussion.
## References ## References
- ADR-052: alknet-tty wire format (amended — scoped to direct connections) - **ADR-093**: channels pure channel multiplexing (reverses this ADR —
TTY always uses its 5-byte format; the channels layer carries it
transparently; the two-mode design is preserved but differs only in
`BiStream` source, not in parsing)
- ADR-052: alknet-tty wire format (amended — scoped to direct connections
by this ADR; **re-amended by ADR-093 — TTY always uses its 5-byte
format, in both direct and inside-channels modes**)
- ADR-053: TtyBackend trait and TtyHandle (unchanged by this ADR) - ADR-053: TtyBackend trait and TtyHandle (unchanged by this ADR)
- ADR-055: exit-chunk-is-last (generalized by this ADR + ADR-073) - ADR-055: exit-chunk-is-last (generalized by this ADR + ADR-073)
- ADR-057: alknet-tty does not depend on alknet-call (preserved — the - ADR-057: alknet-tty does not depend on alknet-call (preserved — the
channels feature is on alknet-channels, not alknet-call) channels feature is on alknet-channels, not alknet-call)
- ADR-071: channels wire format (the 9-byte format the channels path uses) - ADR-071: channels wire format (the 9-byte format the channels path
uses; **amended by ADR-093 — 8-byte format, no `stream_type`**)
- ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the accessor the - ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the accessor the
channels path uses) channels path uses; **amended by ADR-093 — `into_sub_streams()`
removed**)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision
that enables the reversal — `accept_bi` returns `BiStream`)
- ADR-061: DockerTtyBackend in alknet-docker (the feature-gated dependency - ADR-061: DockerTtyBackend in alknet-docker (the feature-gated dependency
pattern this ADR mirrors) pattern this ADR mirrors)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-3, §OQ-CH-02, - `docs/research/alknet-channels/phase-0-findings.md` §DP-3, §OQ-CH-02,
@@ -128,8 +128,13 @@ existing, so shape convergence is observable).
## References ## References
- ADR-074: ChannelBidiStreamSource (the `accept_bi` that yields the stream - ADR-074: ChannelBidiStreamSource (the `accept_bi` that yields the
pair the pumps operate on) `BiStream` the pumps operate on, as amended by ADR-093)
- ADR-093: channels pure channel multiplexing (the `BiStream`-only
accessor decision this ADR's two-pump pattern builds on)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision —
`accept_bi` returns `BiStream`; the two-pump pattern calls
`tokio::io::split(bidi)` for its halves)
- ADR-055: exit-chunk-is-last (the three-pump TTY invariant — the pattern - ADR-055: exit-chunk-is-last (the three-pump TTY invariant — the pattern
this ADR does NOT touch) this ADR does NOT touch)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #7 (the - `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #7 (the
@@ -113,11 +113,13 @@ do the protocol work.
This ADR defines the relay *contract* (translate channel 0, byte-forward This ADR defines the relay *contract* (translate channel 0, byte-forward
data channels with ID rewrite) so the channels crate's `ChannelManager` data channels with ID rewrite) so the channels crate's `ChannelManager`
exposes the interface the relay needs (`open_channel_stream(channel_id, exposes the interface the relay needs (`open_channel_stream(channel_id)
stream_type) -> (SendStream, RecvStream)` for the byte-forward pumps). The -> BiStream` for the byte-forward pumps). The relay *implementation*
relay *implementation* lives in `alknet-hub` (or a downstream hub like lives in `alknet-hub` (or a downstream hub like alkapi), not in
alkapi), not in `alknet-channels`. The channels crate is ALPN-blind and `alknet-channels`. The channels crate is ALPN-blind and does not know it
does not know it is being relayed. is being relayed. The `channel_id` rewrite is a 4-byte field rewrite
within the 8-byte header (per ADR-093); the relay does not parse the
payload.
## Consequences ## Consequences
@@ -170,6 +172,9 @@ auth path. The `channel_id` mapping strategy (`HashMap` per pair) is two-way
terminates on each leg) terminates on each leg)
- ADR-073: channel lifecycle operations (what the hub translates) - ADR-073: channel lifecycle operations (what the hub translates)
- ADR-075: ChannelsAdapter and ChannelManager (the interface the relay uses) - ADR-075: ChannelsAdapter and ChannelManager (the interface the relay uses)
- ADR-093: channels pure channel multiplexing (the 8-byte header the relay
reads/writes; the 4-byte `channel_id` rewrite; the `BiStream`-yielding
`open_channel_stream` interface)
- `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation, - `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation,
§The hub relay, §OQ-CH-11 §The hub relay, §OQ-CH-11
- `docs/architecture/crates/hub/README.md` — the hub crate (the relay - `docs/architecture/crates/hub/README.md` — the hub crate (the relay
@@ -4,7 +4,28 @@
Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API" Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API"
below; amended 2026-07-16 — `connect_quic` removed per ADR-089 §5, see below; amended 2026-07-16 — `connect_quic` removed per ADR-089 §5, see
"Amendment: `connect_quic` removed" below) "Amendment: `connect_quic` removed" below; **amended 2026-07-18 by
ADR-093 — `stream_types` field removed from `open_channel` and `Channel`;
the channels layer has no `stream_type` concept, see "Amendment
(ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
The `stream_types: &[u8]` field is **removed** from `open_channel`'s
signature, and `pub stream_types: Vec<u8>` is **removed** from the
`Channel` struct. The channels layer has no `stream_type` concept
(ADR-093) — the handler owns its sub-stream multiplexing on the
`BiStream` it receives via `Channel.source` (a `ChannelBidiStreamSource`
whose `accept_bi` yields a `BiStream` per ADR-092). The handler's
sub-stream set is implicit in its ALPN's wire format (e.g., TTY's 5-byte
format declares its own `stream_type` set internally; the channels
layer carries the bytes transparently). The `into_sub_streams()` reference
in the `Channel.source` doc comment is moot — `into_sub_streams()` is
removed by ADR-093 (amending ADR-074).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Amendment: `connect_quic` removed (2026-07-16, per ADR-089 §5) ## Amendment: `connect_quic` removed (2026-07-16, per ADR-089 §5)
@@ -265,7 +286,11 @@ decided now.
## References ## References
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`) - ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps) - ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-093: channels pure channel multiplexing (`stream_types` field
removed from `open_channel` and `Channel`; handler owns sub-stream
multiplexing)
- ADR-075: ChannelManager (the shared state `ChannelClient` holds) - ADR-075: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction (the deferred core - OQ-55: AlknetClient / client establishment extraction (the deferred core
concern this ADR does NOT block on) concern this ADR does NOT block on)
@@ -2,7 +2,25 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 by ADR-093 — the 9-byte wire format is now
8-byte; `ChannelSubStreams` / `SubStreamHandle` removed; the channels
layer has no `stream_type` concept — see "Amendment (ADR-093, 2026-07-18)"
below)
## Amendment (ADR-093, 2026-07-18)
The wire format in `channels-core` is now **8-byte** (not 9-byte); the
`ChannelSubStreams` / `SubStreamHandle` typed destructure accessor is
**removed** (the channels layer has no `stream_type` concept —
`accept_bi` yields a `BiStream`, and the handler owns its sub-stream
multiplexing). The channel 0 pre-negotiation in `channels-call` no
longer constructs "reassembly buffers with `stream_types` [0, 1]" — it
constructs one reassembly buffer for `channel_id = 0`, yielding a
`BiStream` to the `CallAdapter`. The "What moves where" table's "9-byte
wire format" row is now "8-byte wire format"; the `ChannelSubStreams`
row is removed. The two-crate split (`channels-core` /
`channels-call`), the dep graph, and the "hub and worker are consumers"
principle are unchanged. See ADR-093 for the resolution rationale.
## Context ## Context
@@ -212,10 +230,15 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
- ADR-003: crate decomposition (no-handler-depends-on-another-handler — - ADR-003: crate decomposition (no-handler-depends-on-another-handler —
preserved; the channels sub-crates depend on core/call, not on handlers) preserved; the channels sub-crates depend on core/call, not on handlers)
- ADR-071: channels wire format (revised — substrate simplification; the - ADR-071: channels wire format (revised — substrate simplification; the
wire format is in `channels-core`) wire format is in `channels-core`; **amended by ADR-093 — 8-byte header,
no `stream_type`**)
- ADR-093: channels pure channel multiplexing (amends this ADR — 8-byte
wire format; `ChannelSubStreams` / `SubStreamHandle` removed; the
channels layer has no `stream_type` concept)
- ADR-072: channel 0 pre-negotiated (moves to `channels-call`) - ADR-072: channel 0 pre-negotiated (moves to `channels-call`)
- ADR-073: channel lifecycle operations (move to `channels-call`) - ADR-073: channel lifecycle operations (move to `channels-call`)
- ADR-074: ChannelBidiStreamSource (in `channels-core`) - ADR-074: ChannelBidiStreamSource (in `channels-core`; **amended by
ADR-093 — `into_sub_streams` removed, `accept_bi` yields `BiStream`**)
- ADR-075: ChannelsAdapter and ChannelManager (split: core demux in - ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
`channels-core`, call coupling in `channels-call`) `channels-core`, call coupling in `channels-call`)
- ADR-079: hub relay (in `alknet-hub` — the hub crate consumes - ADR-079: hub relay (in `alknet-hub` — the hub crate consumes
@@ -151,7 +151,7 @@ alknet mono-repo (the core networking toolkit)
substrate, not part of it. substrate, not part of it.
- Foundational handlers depend on `alknet-core` (for - Foundational handlers depend on `alknet-core` (for
`ProtocolHandler`, `Connection`) and/or `alknet-channels` (for `ProtocolHandler`, `Connection`) and/or `alknet-channels` (for
`ChannelBidiStreamSource`, `into_sub_streams`). No handler depends `ChannelBidiStreamSource`). No handler depends
on another handler — cross-handler communication goes through on another handler — cross-handler communication goes through
`alknet/call` on channel 0. `alknet/call` on channel 0.
- `alknet-vault` is standalone (zero alknet crate dependencies — ADR-018). - `alknet-vault` is standalone (zero alknet crate dependencies — ADR-018).
@@ -7,7 +7,24 @@ amends ADR-065's `from_stream` / `from_bidi` constructors; amends
ADR-074's `ChannelBidiStreamSource::accept_bi` return type; ADR-074's `ChannelBidiStreamSource::accept_bi` return type;
resurrects ADR-007's `BiStream` trait as the handler-facing leaf type; resurrects ADR-007's `BiStream` trait as the handler-facing leaf type;
supersedes the "two Phase 6 issues" framing in supersedes the "two Phase 6 issues" framing in
`docs/research/alknet-crate-extraction/findings.md`) `docs/research/alknet-crate-extraction/findings.md`; **`into_sub_streams()`
preservation subsequently reversed by ADR-093 (2026-07-18) — see the
note at the bottom of this ADR**)
> **Note on `into_sub_streams()` (added 2026-07-18, ADR-093):** This ADR's
> body states `into_sub_streams()` (ADR-074) is "preserved" as the
> second accessor alongside `accept_bi`, because TTY's named
> unidirectional sub-streams are the case that justifies keeping
> `SendStream` / `RecvStream`. ADR-093 reverses that preservation: the
> channels layer has no `stream_type` concept, `into_sub_streams()` is
> removed, and TTY sub-demuxes its `BiStream` via its own 5-byte format
> (the same code TTY runs in direct mode). `SendStream` / `RecvStream`
> collapse to thin newtypes over `Box<dyn Async* + Send + Unpin>` as
> this ADR specifies, but their only consumer is the channels
> reassembly path's internal join (constructing a `BiStream` from split
> halves), not `into_sub_streams()`. See ADR-093 for the resolution
> rationale (the channels layer is pure channel multiplexing; the
> handler owns its sub-stream multiplexing on the `BiStream`).
## Context ## Context
@@ -0,0 +1,485 @@
# ADR-093: alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
## Status
Accepted (amends ADR-071 — wire format is 8 bytes, not 9, and the channels
layer has no `stream_type` concept; amends ADR-074 — `into_sub_streams()`
removed, `accept_bi` is the only accessor and yields one `BiStream` per
channel; reverses ADR-077 — TTY always uses its 5-byte format, the channels
layer carries it transparently in the payload)
## Context
ADR-071 committed the channels wire format as a 9-byte chunk header
(`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte extension of
TTY's 5-byte format, with `stream_type` carried in the channels header
and decomposed into unidirectional halves (0/1/2 = data write/read/err,
3/4/5 = control write/read/err, `% 3` formula). ADR-074 added a second
accessor (`into_sub_streams()`) alongside `accept_bi` for handlers that
need typed sub-streams (TTY's stdin/stdout/stderr/ctrl-in/ctrl-out).
ADR-077 split TTY's wire format into two modes — direct (5-byte) and
inside-channels (the channels layer de-chunks and the adapter destructures
via `into_sub_streams()`).
The stream-unification research
(`docs/research/stream-unification/findings.md`, 2026-07-18) surfaced that
these three decisions share one root: the channels layer carries a
concept (`stream_type`) it doesn't own. The 9-byte header bakes TTY's
sub-stream multiplexing into the channels wire format. The
`into_sub_streams()` accessor exists because the channels layer reassembles
per-`stream_type` and needs to expose the result. The two-mode TTY design
exists because the channels layer's `stream_type` overlaps with TTY's own
`stream_type`. The mod 2/mod 3/mod 4 numbering question (settled as mod 3
in ADR-071 revised) was a symptom of this overlap — a numbering convention
for a concept the channels layer shouldn't carry.
### The structural question
The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a `BiStream`;
any handler gets `accept_bi()` and treats the channel as a duplex
stream. Uniform, transport-agnostic, recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries
stdin/stdout/stderr/control; the handler destructures via
`into_sub_streams()`. Carries what the source produces.
The tension is real when a sub-stream is *unidirectional* (stderr). You
can't represent stderr as a `BiStream` without wasting the write half;
you can't make it a "third half" (mod 3) without breaking pair symmetry;
you can't make the channel a single `BiStream` without losing the
stdout/stderr distinction.
ADR-074's two-accessor design resolves this by making the "pass a stream
to/from any ALPN" objective *qualified* — it applies to single-stream
channels (tunnel, SSH, call), not multi-stream channels (TTY). The mod
2/mod 3/mod 4 numbering was a symptom of that qualified design.
### The resolution: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed
by `channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them.
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-092, already landed). No `into_sub_streams()`, no
second-class accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (its 5-byte format). Tunnel
uses the `BiStream` as raw bytes. Call length-prefixes JSON. SSH runs
its own channel protocol. The channels layer carries the bytes
transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.** The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. `stream_type` is the inner layer's
framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
`STREAM_CTRL_OUT = 4` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control. The "control isn't actually bidirectional" flaw
is fixed at the TTY layer, not the channels layer.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload. Each level is the same shape —
`BiStream → accept_bi → N BiStreams`.
### The wire format decision: 8 bytes
The channels wire format is **8 bytes**: `[channel_id:u32 BE][length:u32
BE]` followed by an opaque payload. The channels layer owns `channel_id`
and `length`; the payload is the handler's framing, carried transparently.
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
was considered and rejected. The 9-byte format puts `stream_type` in the
channels header, which means the channels layer carries a concept it
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
5-byte header with `channel_id` prepended), but for non-TTY handlers
(tunnel, call, SSH) the `stream_type` byte is dead weight — the channels
layer carries a byte it doesn't understand, and the handler ignores a
byte in a header it doesn't control.
The 8-byte format is uniform across all handlers: the channels layer
carries `channel_id` + `length` + opaque payload. Every handler parses
its own framing from the payload. The cost is that TTY's `wire.rs` is
called from a payload buffer rather than directly from the wire, and the
total header for a TTY chunk is 13 bytes (8 channels + 5 TTY) instead of
9. The two length fields are close but not identical (`ch_len = tty_len +
5`); for typical TTY chunks (4 KiB+), the 5-byte overhead is ~0.1%, and
the trade is clean separation of concerns. See "Consequences" for the
full cost/benefit.
### The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
### Why this can land now
Three things changed since ADR-071/074/077 were accepted:
1. **ADR-092 landed `BiStream` as the handler leaf.** `accept_bi()`
returns a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype), not
a split `(SendStream, RecvStream)` pair. The join moves into core's
quinn/iroh/stream impls (once per source, invisible to handlers). This
ADR's "every channel is a `BiStream`" is the channels-layer
consequence of ADR-092's handler-leaf decision — the research-then-sync
pattern applied: ADR-092 settled the transport leaf, this ADR settles
the multiplexing layer above it.
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it into
`STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's 5-byte
format*, not at the channels layer. This removed the load-bearing
reason for the channels layer to carry `stream_type`: the control
bidirectionality fix is a TTY-internal concern, not a channels-layer
concern. ADR-077's two-mode TTY design was motivated by the channels
layer carrying control; with control moved inside TTY, the motivation
dissolves.
3. **No production constraint.** The develop branch is a rewrite of main
(pre-alpha). The channels crate doesn't exist yet (per ADR-081, it's
planned as `alknet-channels-core` + `alknet-channels-call`). The
decision is purely "what's cleanest," not "what's least disruptive."
The 9-byte POC validated the per-`channel_id`/`stream_type` routing
mechanism; the 8-byte spec update changes the header before
implementation begins.
### What this ADR does NOT decide
- **The add/strip API shape** (built into read/write vs. a separate
utility): the stream-unification research proposed `add_channel_id` /
`strip_channel_id` as standalone functions. Ideally the header is
built into the read/write path so the utility isn't needed at the
handler boundary — but there may be a generalized reason to expose it
(recursive composition, test helpers, the hub relay's `channel_id`
rewrite). The exact API shape is an implementation detail for the
channels crate, tracked as OQ-68. The *contract* — the channels layer
strips its 8-byte header on read and the handler parses its own framing
from the payload — is decided here; the *function surface* is not.
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
an `AsyncRead`. Adapting it to read from a payload buffer (`&[u8]` or
`Cursor<Bytes>`) is a small, well-scoped change (the framing logic —
stream_type constants, length validation, control message parsing — is
unchanged). This is an implementation concern for the channels + TTY
integration, not an architecture decision.
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
bounded-buffer backpressure (ADR-076) is the v1 mechanism; full
windowing is an additive extension that doesn't change the wire
format. OQ-56 stays deferred(scope).
## Decision
### 1. The channels wire format is 8 bytes
```
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
8 bytes of header, followed by `length` bytes of opaque payload. The
channels layer owns `channel_id` and `length`; the payload is the
handler's framing, carried transparently.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §5). |
The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. What was the channels header's
`stream_type` byte is now the first byte of the payload, owned by the
handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol).
This amends ADR-071: the wire format is 8 bytes, not 9; the
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
removed from the channels layer. The stream_type concept survives in
TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
ADR-074's `into_sub_streams()` / `ChannelSubStreams` / `SubStreamHandle`
are removed. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-092). Every handler —
TTY, tunnel, SSH, call — receives a `Connection`, calls `accept_bi()`
once, gets a `BiStream`, and sub-multiplexes it however it wants.
This amends ADR-074: the two-accessor design (`accept_bi` for generic
handlers, `into_sub_streams` for typed handlers) collapses to one
accessor. The "typed handler path" (ADR-074's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format —
the same code TTY runs in direct mode. ADR-074's yield-once `accept_bi`
contract is preserved; the `into_sub_streams()` accessor is the amended
part.
### 3. TTY always uses its 5-byte format; the channels layer carries it transparently
ADR-077's two-mode TTY design (direct vs inside-channels) is reversed.
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-052) is
TTY's internal format, used in *both* direct mode and inside-channels
mode. The two modes differ only in *where the `BiStream` comes from*
(a top-level `alknet/tty` connection vs a `channel/open` with ALPN
`alknet/tty`), not in *how TTY parses it*. The same `wire.rs` code runs
in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
and hands TTY the payload bytes. TTY parses its 5-byte header from the
payload. The channels layer carries TTY's 5-byte chunks transparently
in its payload — no shared fields, no leaked abstraction, no
double-chunking concern (the 13-byte total header is 8 channels + 5
TTY, not 8 + 9; the channels `length` is always `tty_len + 5`).
This reverses ADR-077: the 5-byte format is NOT scoped to direct — it's
TTY's internal format, carried transparently in the channels payload.
The `channels` feature on `alknet-tty` becomes "run TTY's sub-demux on a
channels-backed `BiStream`" — the same code as direct mode, different
`BiStream` source. The control channel split (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't
know about it.
### 4. The add/strip composition
The channels layer's read path strips the 8-byte header and hands the
payload to the handler. The write path prepends the 8-byte header
(`add_channel_id`) onto the handler's output. The handler never sees
the `channel_id`; it sees only its own framing (the payload bytes).
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The composition is uniform — the same shape at every level. A
`alknet/channels`-inside-`alknet/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its own
8-byte header from the payload — same code, same shape, each level.
### 5. What does NOT change
- **ADR-092's `BiStream` leaf** — unchanged. This ADR is the
channels-layer consequence of ADR-092: `accept_bi` yields a `BiStream`,
handlers sub-multiplex it. The two ADRs compose (ADR-092 settles the
transport leaf; this ADR settles the multiplexing layer above it).
- **`ProtocolHandler` trait shape** (ADR-002) — unchanged. Handlers
receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alknet/call`** (ADR-072) — unchanged.
Channel 0's chunks have `channel_id = 0` in the 8-byte header. The call
protocol's `EventEnvelope` framing is the payload; the channels layer
carries it transparently.
- **Channel lifecycle operations** (ADR-073) — unchanged. The four
operations (`channel/open`/`close`/`control`/`resources/subscribe`) and
their `direction` semantics are call-protocol operations on channel 0,
not channels-wire-format concerns.
- **`ChannelsAdapter` / `ChannelManager` split** (ADR-075) —
structurally unchanged. The demux loop reads 8-byte headers (not
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
transport-blind. The `stream_types` field on `channel/open` and
`ChannelState` is removed (the channels layer doesn't track
per-stream-type reassembly buffers; it tracks one reassembly buffer
per `channel_id`, yielding a `BiStream`).
- **Backpressure, channel limits, ID reuse** (ADR-076) — unchanged. The
bounded-buffer backpressure is per-`channel_id` (was per-
`(channel_id, stream_type)`; now per-`channel_id` since there's one
reassembly buffer per channel). The 256-channel cap, 1 MiB default,
and monotonic-ID-with-wrap strategy are unchanged.
- **Two-pump shutdown-on-completion** (ADR-078) — unchanged. Tunnel/SSH
handlers call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
- **Hub relay** (ADR-079) — unchanged in contract. The hub translates
`channel/open` on channel 0 and byte-forwards data channels with
`channel_id` rewrite. The relay reads 8-byte headers (not 9-byte) and
rewrites the `channel_id` field (a 4-byte rewrite within the 8-byte
header, not a 9-byte header). The relay does not parse the payload.
- **`ChannelClient`** (ADR-080) — unchanged in API. `from_connection`
primary, `open_channel` returns a `Channel`. The `stream_types` field
on `open_channel` and `Channel` is removed (the channels layer doesn't
negotiate per-stream-type sets; the handler owns its sub-stream
multiplexing). The `channel:stream_type_unavailable` error code is
removed (the channels layer can't refuse a `stream_type` it doesn't
know about).
- **Sub-crate decomposition** (ADR-081) — unchanged. `channels-core`
(pure multiplexer, depends on `alknet-core` only) / `channels-call`
(channel 0 pre-negotiation + lifecycle op registrations, depends on
`channels-core` + `alknet-call`). The 8-byte wire format, demux/mux,
and `ChannelBidiStreamSource` are in `channels-core`; the call-protocol
coupling is in `channels-call`.
- **`BidiStreamSource` trait** (ADR-070) — unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per ADR-092, already landed).
## Consequences
**Positive:**
- **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in its
mental model. The handler owns its framing entirely. This dissolves
the mod 2/mod 3/mod 4 question at the channels layer (there's nothing
to decompose) and fixes the "control isn't actually bidirectional" TTY
flaw at the TTY layer (where it lives, not the channels layer).
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
receive the same shape: a `BiStream`. No handler gets a `stream_type`
byte it doesn't use; no handler needs a second accessor
(`into_sub_streams`) to reach its sub-streams. The channels layer's
API surface is `accept_bi -> BiStream`, period.
- **Recursive composition is literal.** A `alknet/channels` channel runs
another channels demux on its `BiStream`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from the
payload. Same code, same shape, each level. This is a property, not a
feature — the primary use case is one level of multiplexing, but the
add/strip composition makes the recursion cleaner than ADR-071's
group framing did.
- **The `into_sub_streams()` accessor and its consuming handler code are
removed.** This is a net simplification: one accessor, one handler
path, no downcast / extension trait / "two paths" ergonomics question
(which ADR-074 left as an implementation detail). The handler crate
destructures its `BiStream` via its own framing (TTY's 5-byte format),
not via a channels-crate-provided typed accessor.
- **TTY's `wire.rs` runs unchanged in both modes.** Direct mode and
inside-channels mode use the same code; only the `BiStream` source
differs. ADR-077's `drive_session_direct` / `drive_session_channels`
split collapses to one `drive_session` function. The `channels` feature
on `alknet-tty` becomes a thin wrapper that gets the `BiStream` from a
channels-backed `Connection` instead of a top-level one.
- **The channels layer is WASM-compatible by construction.** The 8-byte
header's core is pure byte manipulation (the sync core compiles under
`wasm32-unknown-unknown`, validated by the POC). The 8-byte format is
simpler than the 9-byte (one fewer field to parse), strengthening the
WASM-clean property.
**Negative:**
- **5 extra bytes per TTY chunk.** The total header for a TTY chunk
inside channels is 13 bytes (8 channels + 5 TTY), not 9. The two length
fields are close but not identical (`ch_len = tty_len + 5`). For
typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme
multiplexing scenarios, the clean separation is worth the trade-off;
for high-throughput bulk transfer, the escape hatch is multi-connection
(one channels connection per leg), not stripping the header. This is
the documented cost of the clean separation; the alternative (9-byte
header with `stream_type` in the channels layer) carries a concept the
channels layer doesn't own, which is the root cause this ADR addresses.
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
reads from an `AsyncRead` (the transport stream). Inside channels, it
reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the bytes
the channels layer handed it after stripping its 8-byte header. The
framing logic (stream_type constants, length validation, control
message parsing) is unchanged. This is a bounded, well-scoped
implementation change, not an architecture change. The same adaptation
applies to any handler that parses its own framing from a payload
buffer (call's `EventEnvelope` framing already reads from a buffer;
tunnel and SSH don't parse the payload, so no adaptation).
- **`channel/open` loses the `stream_types` field.** ADR-073's
`channel/open` input included `stream_types: [u8]` (the active sub-stream
set) and the response echoed the negotiated set. Under this ADR, the
channels layer doesn't negotiate sub-stream sets — the handler owns
its sub-stream multiplexing. The `stream_types` field is removed from
`channel/open` (and from the `channel:stream_type_unavailable` error
code). The `alpn` and `params` fields remain; the handler's sub-stream
set is implicit in its ALPN's wire format. This is a small wire-format
change to `channel/open` (one field removed); since the channels crate
isn't implemented yet, there's no migration cost.
- **`ChannelState.streams: HashMap<u8, ReassemblyBuffer>` becomes
`ChannelState.reassembly: ReassemblyBuffer` (one per channel, not per
`(channel_id, stream_type)`).** This is an internal simplification
(fewer reassembly buffers, simpler drain logic) but is an
implementation change, not an architecture one. The bounded-buffer
backpressure (ADR-076) is per-`channel_id` now, not per-
`(channel_id, stream_type)` — the 1 MiB default and the 256-channel cap
are unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB
for a TTY channel with 5 active stream_types). This is a net
improvement (lower memory ceiling per channel), not a regression.
## Door type
**One-way (wire format, accessor removal, two-mode reversal).** The 8-byte
chunk header layout (`channel_id:u32 + length:u32`), the removal of
`stream_type` from the channels header, and the removal of
`into_sub_streams()` are wire-format and API commitments. Changing them
after the channels crate is implemented and handlers are written against
them requires a version migration. Since the channels crate doesn't exist
yet, the one-way door is being cast now, before implementation — the
right time to cast a one-way door.
The reversal of ADR-077 (TTY always uses its 5-byte format) is one-way in
the same sense: once TTY's `wire.rs` runs in both modes (direct and
inside-channels), re-introducing a separate inside-channels mode would be
a rewrite of TTY's session driver. The trade is one unified session
driver now vs. two-mode maintenance forever.
The add/strip API shape (OQ-68) is a **two-way door** — whether the
header add/strip is built into the read/write path or exposed as a
standalone utility is an implementation detail that can change without
breaking the wire format or the handler contract.
## References
- ADR-071: channels wire format (amended — wire format is 8 bytes, not
9; `stream_type` removed from the channels header; the stream_type
decomposition is removed from the channels layer)
- ADR-074: ChannelBidiStreamSource (amended — `into_sub_streams()`
removed; `accept_bi` is the only accessor, yields one `BiStream` per
channel)
- ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte
format; the channels layer carries it transparently in the payload;
the two-mode design is preserved but differs only in `BiStream`
source, not in parsing)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf layer this
ADR builds on — `accept_bi` returns `BiStream`; `from_bidi` is the only
public stream constructor)
- ADR-070: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- ADR-072: channel 0 pre-negotiated `alknet/call` (unchanged — channel 0's
chunks have `channel_id = 0` in the 8-byte header; the call protocol's
framing is the payload)
- ADR-073: channel lifecycle operations (amended — `stream_types` field
removed from `channel/open`; `channel:stream_type_unavailable` error
code removed)
- ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per
channel)
- ADR-076: backpressure, channel limits, ID reuse (unchanged —
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-079: hub relay (unchanged in contract — 8-byte header, 4-byte
`channel_id` rewrite, payload byte-forwarded)
- ADR-080: `ChannelClient` (amended — `stream_types` field removed from
`open_channel` and `Channel`)
- ADR-081: sub-crate decomposition (unchanged — 8-byte wire format in
`channels-core`; call-protocol coupling in `channels-call`)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload; the control channel split
from Phase 7 is TTY-internal)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the structural question and the resolution this ADR commits
- `docs/research/alknet-crate-extraction/findings.md` Phase 8 — the
spec-cleanup phase this ADR is the substance of
- `/workspace/alknet-channels-poc/` — the POC that validated the
per-`channel_id`/`stream_type` routing mechanism (the mechanism
supports any convention; this ADR says the channels layer doesn't have
a convention, the handler does)
+3 -2
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-17 last_updated: 2026-07-18
--- ---
# Open Questions # Open Questions
@@ -193,7 +193,8 @@ Door type is separate from whether a decision is made. A two-way door is a decis
| OQ | Title | Status | Door | Pri | | OQ | Title | Status | Door | Pri |
|----|-------|--------|------|-----| |----|-------|--------|------|-----|
| [OQ-56](questions/056-full-channel-level-flow-control-windowing.md) | Full Channel-Level Flow-Control Windowing | deferred(scope) | two | low | | [OQ-56](questions/056-full-channel-level-flow-control-windowing.md) | Full Channel-Level Flow-Control Windowing | deferred(scope) | two | low |
| [OQ-57](questions/057-two-pump-helper-extraction.md) | Two-Pump Helper Extraction to alknet-core | deferred(scope) | two | low | | [OQ-57](questions/057-two-pump-helper-extraction.md) | Two-Pump Helper Extraction to alknet-Core | deferred(scope) | two | low |
| [OQ-68](questions/068-channels-add-strip-api-shape.md) | Channels Add/Strip API Shape (Built-In vs. Utility) | open | two | low |
### alknet-tls ### alknet-tls
+2 -1
View File
@@ -371,7 +371,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign — ACME + RawKey Decoupling | `TlsIdentity::Acme` variant + two-phase server config; `RawKey` uses `ed25519-dalek` (not `iroh::SecretKey`); `acme` feature gate | | [027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign — ACME + RawKey Decoupling | `TlsIdentity::Acme` variant + two-phase server config; `RawKey` uses `ed25519-dalek` (not `iroh::SecretKey`); `acme` feature gate |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections — unblocks TCP+TLS, SSH channels, WebTransport, wasm | | [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections — unblocks TCP+TLS, SSH channels, WebTransport, wasm |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | Open `Connection` for extension — downstream crates add connection shapes without editing core | | [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | Open `Connection` for extension — downstream crates add connection shapes without editing core |
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format | 9-byte chunk header; N channels over one transport stream | | [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format | 8-byte chunk header (amended by ADR-093); N channels over one transport stream; channels layer has no `stream_type` concept |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels Pure Channel Multiplexing | 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte; amends ADR-071/074/077 |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate `channel/open`, byte-forward data channels; the hub never runs protocol-specific handlers | | [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate `channel/open`, byte-forward data channels; the hub never runs protocol-specific handlers |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Shared `TlsServerConfig` across quinn + TCP+TLS + iroh; one ACME state machine | | [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Shared `TlsServerConfig` across quinn + TCP+TLS + iroh; one ACME state machine |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT; endpoint extracted from `alknet-core` into `alknet-endpoint` (Am. 2026-07-15) | | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT; endpoint extracted from `alknet-core` into `alknet-endpoint` (Am. 2026-07-15) |
@@ -47,11 +47,12 @@
can't keep up; no unbounded buffer breaks the chain. can't keep up; no unbounded buffer breaks the chain.
The two-way-door reversal — a per-stream window-update control message The two-way-door reversal — a per-stream window-update control message
on stream_type 3 — is an additive extension to the control channel on TTY's `STREAM_CTRL_IN` (stream_type 3) — is an additive extension
(a new `ControlMessage` variant), not a wire-format header change. It to the control channel (a new `ControlMessage` variant), not a
is not the expected path; it is noted in ADR-052's consequences as the wire-format header change. It is not the expected path; it is noted
cheap reversal if a flow-control problem ever surfaces that QUIC's in ADR-052's consequences as the cheap reversal if a flow-control
defaults cannot handle (e.g., a pathological stream that needs problem ever surfaces that QUIC's defaults cannot handle (e.g., a
pathological stream that needs
sub-QUIC-window backpressure signaling). Tuning concerns (read buffer sub-QUIC-window backpressure signaling). Tuning concerns (read buffer
size, channel depth) are implementation-level, not architectural, and size, channel depth) are implementation-level, not architectural, and
don't warrant an ADR. don't warrant an ADR.
@@ -8,14 +8,15 @@
- **Priority**: low - **Priority**: low
- **Resolution**: Either a zero-length stdin chunk (stream_type 0, - **Resolution**: Either a zero-length stdin chunk (stream_type 0,
length 0 — the docker POC's sentinel) or a `{"type":"eof"}` length 0 — the docker POC's sentinel) or a `{"type":"eof"}`
control chunk (stream_type 3 — the tty POC's explicit signal) closes control chunk (TTY's `STREAM_CTRL_IN`, stream_type 3 — the tty POC's
the client's stdin. Both are accepted by the adapter; the spec explicit signal) closes the client's stdin. Both are accepted by the
recommends `eof` for explicitness (it's a control message, not a adapter; the spec recommends `eof` for explicitness (it's a control
data-length hack). The adapter handles both identically: signal EOF message, not a data-length hack). The adapter handles both
to the backend's stdin (`ChildStdin::drop` / PTY writer close) and identically: signal EOF to the backend's stdin (`ChildStdin::drop` /
keep pumping stdout until the exit resolves — the client may still PTY writer close) and keep pumping stdout until the exit resolves —
want to receive remaining output + the exit code. A third path the client may still want to receive remaining output + the exit
(client closes the write half of the bidi stream) is also accepted code. A third path (client closes the write half of the bidi stream)
and handled the same way. See ADR-052 and `tty-wire.md`. is also accepted and handled the same way. See ADR-052 and
`tty-wire.md`.
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md), - **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
[tty-adapter.md](crates/tty/tty-adapter.md) [tty-adapter.md](crates/tty/tty-adapter.md)
@@ -41,13 +41,13 @@
**Option B — WebSocket carries channels (extends/supersedes **Option B — WebSocket carries channels (extends/supersedes
ADR-048).** The browser opens a WebSocket and gets a channels ADR-048).** The browser opens a WebSocket and gets a channels
connection — the 9-byte chunk format (ADR-071) over WebSocket binary connection — the 8-byte chunk format (ADR-071, as amended by ADR-093)
frames, with channel 0 as `alknet/call` and data channels opened via over WebSocket binary frames, with channel 0 as `alknet/call` and
`channel/open`. The browser is a full channels participant: it can data channels opened via `channel/open`. The browser is a full
open TTY channels, tunnels, etc. through the same WebSocket. The channels participant: it can open TTY channels, tunnels, etc. through
hub relay (ADR-079) works unchanged — the browser leg is a channels the same WebSocket. The hub relay (ADR-079) works unchanged — the
connection, same as a native leg. This is the "browser as browser leg is a channels connection, same as a native leg. This is
channels client" path. the "browser as channels client" path.
The trade-off: Option B commits to the channels-over-WebSocket The trade-off: Option B commits to the channels-over-WebSocket
framing as a browser wire format (one-way door), but makes the framing as a browser wire format (one-way door), but makes the
@@ -73,8 +73,10 @@
format (ADR-071); only the transport differs. format (ADR-071); only the transport differs.
- **Cross-references**: ADR-048 (WebSocket carries the native - **Cross-references**: ADR-048 (WebSocket carries the native
call-protocol session — the current decision this OQ may supersede call-protocol session — the current decision this OQ may supersede
or extend), ADR-071 (channels wire format — the 9-byte chunk format or extend), ADR-071 (channels wire format — the 8-byte chunk format
that would ride over WebSocket binary frames), ADR-079 (hub relay — that would ride over WebSocket binary frames, as amended by ADR-093),
unchanged if the browser is a channels client), ADR-086 §3 (the web ADR-093 (channels pure channel multiplexing — the umbrella decision),
config advertises `alknet/channels` for this path), ADR-044 ADR-079 (hub relay — unchanged if the browser is a channels client),
(WebTransport deferred; WebSocket is the v1 browser path) ADR-086 §3 (the web config advertises `alknet/channels` for this
path), ADR-044 (WebTransport deferred; WebSocket is the v1 browser
path)
@@ -0,0 +1,78 @@
# OQ-68: Channels Add/Strip API Shape (Built-In vs. Utility)
- **Origin**: `docs/research/stream-unification/findings.md` §"The
add/strip utility"; `docs/architecture/decisions/093-channels-pure-channel-multiplexing.md`
§"What this ADR does NOT decide"; `docs/architecture/crates/channels/channels-wire.md`
§"The add/strip composition"
- **Status**: open
- **Door type**: two-way (the *contract* — channels strips its 8-byte
header on read, handler parses its own framing from the payload — is
decided in ADR-093; the *function surface* — whether add/strip is
built into the read/write path or exposed as a standalone utility —
is reversible without breaking the wire format or the handler contract)
- **Priority**: low
- **Impacts**: None — the add/strip *contract* is decided (ADR-093);
this OQ is about the API shape, not the contract. The channels crate
can ship with either shape and switch later without a wire-format
change.
- **Investigation target**: work through 2+ example handler
compositions (TTY inside channels, tunnel inside channels, a
recursive `alknet/channels`-inside-`alknet/channels` composition) to
see where the add/strip naturally lives. If the header add/strip is
built into the channels read/write path, the handler never sees the
`channel_id` — the `BiStream` the handler receives is the payload
bytes. If the add/strip is a standalone utility, the handler (or a
test helper, or the hub relay's `channel_id` rewrite) can call it
explicitly. The question is which shape is cleaner for the common
case (handler inside channels) without foreclosing the
less-common cases (recursive composition, the hub relay, test
helpers).
- **Resolution**: Not yet decided. The two options:
**Option A — Built into read/write (the default).** The channels
layer's `accept_bi` returns a `BiStream` whose bytes are the payload
(the channels header is stripped internally). The handler's
`AsyncWrite` on the `BiStream` re-adds the 8-byte header internally
(the handler writes payload bytes; the channels layer frames them).
The handler never sees the `channel_id`; the add/strip is invisible.
This is the cleanest shape for the common case (a handler inside
channels). The utility (`add_channel_id` / `strip_channel_id`) is
still available internally (the channels layer calls it), and may
be exposed publicly for the less-common cases (recursive composition,
the hub relay, test helpers) — but the handler boundary doesn't
require it.
**Option B — Standalone utility (the explicit alternative).** The
channels layer exposes `add_channel_id(channel_id, payload_bytes)
-> chunk` and `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` as the primary API. The handler (or a wrapper, or a
test helper) calls them explicitly. This is the shape the
stream-unification research proposed. It's more explicit (the
handler sees the `channel_id`, can log it, can route on it) but
pushes the add/strip to the handler boundary, not the channels
read/write path. The handler's `BiStream` is the raw chunk bytes
(header + payload), not the payload alone.
The trade-off: Option A is cleaner for the common case (the handler
doesn't care about `channel_id`; the channels layer owns it
entirely) but may require an escape hatch for the less-common cases
(the hub relay needs to rewrite `channel_id`; recursive composition
needs to re-add a header; test helpers may want to construct chunks
directly). Option B is more uniform (the same add/strip pair at every
level, including the handler boundary) but pushes work to the
handler that the channels layer could own. The investigation target
(2+ example compositions) is what surfaces which shape is cleaner in
practice.
This question is decision-ready when the channels crate's
implementation begins. Until then, the *contract* (channels strips,
handler parses payload) is decided (ADR-093); the *function surface*
is not.
- **Cross-references**: ADR-093 (the umbrella decision that decides the
add/strip *contract* and leaves the *function surface* to this OQ);
`docs/research/stream-unification/findings.md` §"The add/strip
utility" (the research that proposed the utility);
`docs/architecture/crates/channels/channels-wire.md` §"The add/strip
composition" (the spec that records the contract and points to this
OQ)