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:
1 parent
c2b7055a64
commit
a3cb44968e
31 files changed
+1490
-508
No files matched your search
+60
-49
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user