Phase 1 architecture for alknet-channels (multiplexing proxy on alknet/channels). Grounded in the completed de-risk POC (28 tests) and the landed ADR-070 (BidiStreamSource trait + Connection::from_source). ADRs: - 071: 9-byte chunk wire format (generalizes TTY's 5-byte) - 072: channel 0 pre-negotiated as alknet/call (no special control plane) - 073: channel lifecycle operations on the call protocol — channel/open, close, control, resources/subscribe; direction field pinned; subscribe from day one (not poll-for-v1 — StreamingHandler machinery exists) - 074: ChannelBidiStreamSource implements BidiStreamSource (ADR-070); into_sub_streams() typed accessor for TTY; accept_bi() generic path - 075: ChannelsAdapter + ChannelManager; REQ-CH-01..04 wire invariants - 076: bounded-buffer backpressure (1 MiB), 256-channel cap, monotonic IDs - 077: TTY inside channels uses sub-streams, not own wire format; amends ADR-052 scope to direct-connect TTY; channels feature on tty - 078: two-pump shutdown-on-completion contract (handler-level) - 079: hub relay translates channel 0, byte-forwards data channels - 080: ChannelClient (QUIC-only); AlknetClient core extraction deferred (OQ-55) Spec docs: overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client. OQ-56 (full windowing) and OQ-57 (two-pump helper extraction) are genuine deferred(scope) deferrals with concrete blocking conditions; the contracts are decided, only the extensions are deferred. Hedging audit converted three research hedges into decisions: resources/subscribe (not poll), server-assigned IDs (not if-zero-RTT), bounded-buffer (not if-HOL-becomes-a-problem).
6.6 KiB
ADR-080: ChannelClient — the Client Side of a Channels Connection
Status
Accepted
Context
Both sides of a channels connection do the demux/mux work. The server side
is a ProtocolHandler (ChannelsAdapter::handle, ADR-075). The client side
needs a symmetric type — ChannelClient — that opens a transport, runs the
demux/mux, and exposes open_channel(alpn, params) -> Channel to the
application. This is the channels analogue of CallClient (server:
CallAdapter; client: CallClient) in the call protocol.
The phase-0 research (docs/research/alknet-channels/phase-0-findings.md
§OQ-CH-14) clarified that there are two concerns here:
ChannelClient(channels-specific): the client type for channels connections. Decision-ready — build it inalknet-channels, same shape asCallClient.AlknetClient(core, transport-polymorphic): a general downstream- facing client that crates use to connect to an alknet endpoint. Genuinely deferred — blocked on a second transport's client existing (OQ-55 tracks this correctly).ChannelClientover QUIC does not unblockAlknetClientbecause it's the same transport shape asCallClient.
This ADR decides #1. #2 stays deferred per OQ-55.
Decision
ChannelClient in alknet-channels
pub struct ChannelClient {
manager: ChannelManager,
// The transport-side demux/mux, running in a background task.
...
}
impl ChannelClient {
/// Open a channels connection to a peer. Dials the transport (QUIC
/// initially), establishes the channels connection, preinstalls channel
/// 0 (alknet/call), and returns the client.
pub async fn connect(addr: SocketAddr, credentials: CallCredentials)
-> Result<Self, ChannelError>;
/// Open a data channel with the given ALPN and params. Sends
/// `channel/open` on channel 0, waits for the response, and returns
/// the channel's sub-streams.
pub async fn open_channel(
&self,
alpn: &str,
stream_types: &[u8],
params: Value,
direction: ChannelDirection,
) -> Result<Channel, ChannelError>;
/// The call-protocol connection on channel 0, for invoking channel
/// lifecycle operations and any other call ops the peer exposes.
pub fn call(&self) -> &CallConnection;
}
pub struct Channel {
pub channel_id: u32,
pub stream_types: Vec<u8>,
/// The sub-streams, accessible via the BidiStreamSource (accept_bi) or
/// into_sub_streams() — ADR-074.
pub source: ChannelBidiStreamSource,
}
QUIC-only initially
ChannelClient::connect dials a QUIC connection (via the same quinn
endpoint CallClient uses) and wraps it as a channels connection. This is
the same transport shape as CallClient. When a second transport's client
exists (HTTP, TCP+TLS, WebTransport — per OQ-55), the dial can be
generalized. Until then, ChannelClient is QUIC-only — the same posture as
CallClient.
Bidirectionality preserved
The channels protocol is bidirectional — either side can open a channel
(ADR-073 §direction semantics). ChannelClient::open_channel supports both
ChannelDirection::InitiatorToResponder and
ChannelDirection::ResponderToInitiator. The client is not "the client
side" in the sense of only initiating — it can also receive channel/open
requests from the peer (the peer initiates, the client's ChannelManager
responds). This mirrors the call protocol's operation overlay (each side
populates what operations they expose).
This means ChannelClient is not purely a "client" in the request/response
sense — it's one endpoint of a bidirectional channels connection. The name
ChannelClient follows the CallClient convention (the side that dialed),
not a request/response role.
Relationship to AlknetClient (OQ-55 — deferred)
ChannelClient is a standalone client, not a specialization of a core
AlknetClient. The AlknetClient extraction (OQ-55) is genuinely deferred:
blocked on a second transport's client existing, not on a second client
existing. ChannelClient over QUIC is a second client but the same
transport shape as CallClient — it doesn't give enough information to
extract the transport-polymorphic dial seam. Extracting a QUIC-shaped
connector to core and naming it AlknetClient would bake QUIC in as the
establishment shape — the same welding ADR-065 unwound on the server side.
When AlknetClient is eventually extracted (after a second transport's
client exists), ChannelClient and CallClient both refactor onto it.
Until then, they are independent clients with duplicated boilerplate (each
rebuilds verifier selection — ~20 lines). The friction is duplicated
boilerplate, not a missing capability.
Consequences
Positive:
ChannelClientgives the channels crate a symmetric client/server pair, matching the call protocol'sCallAdapter/CallClientshape.- Bidirectionality is preserved — the client can both initiate and receive
channel/open. - The
AlknetClientdeferral (OQ-55) is not blocked byChannelClient— they are independent concerns.ChannelClientbuilds standalone; the core extraction happens later when the blocker clears.
Negative:
ChannelClientduplicates ~20 lines of verifier-selection boilerplate fromCallClient. This is the known cost of not extractingAlknetClientyet (OQ-55). Acceptable until the second transport's client exists.ChannelClientis QUIC-only. A non-QUIC channels client (e.g., a browser over WebTransport) builds separately untilAlknetClientis extracted. This is the same posture asCallClientand is not a channels-specific limitation.
Door type
One-way. The ChannelClient::connect / open_channel / call /
subscribe_resources API is the handler-facing surface; changing it after
consumers exist is a rewrite.
The AlknetClient extraction is a deferred decision (OQ-55,
deferred(scope)), not a door-type attribute. Its door type is two-way (the
extraction is a refactor, not a wire-format change), but it is not decided
in this ADR — see OQ-55 for the blocking condition.
References
- ADR-073: channel lifecycle operations (
open_channelsendschannel/open) - ADR-074: ChannelBidiStreamSource (what
Channel.sourcewraps) - ADR-075: ChannelManager (the shared state
ChannelClientholds) - OQ-55: AlknetClient / client establishment extraction (the deferred core concern this ADR does NOT block on)
docs/research/alknet-channels/phase-0-findings.md§OQ-CH-14 (the research-scope question this ADR carries forward)docs/architecture/crates/call/client-and-adapters.md—CallClient(the shapeChannelClientmirrors)