Files
alknet/docs/architecture/decisions/080-channelclient.md
T
glm-5.2 2313c51f12 docs(arch): add alknet-channels specs — ADRs 071-080, 7 spec docs, OQ-56/57
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).
2026-07-12 12:57:18 +00:00

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:

  1. ChannelClient (channels-specific): the client type for channels connections. Decision-ready — build it in alknet-channels, same shape as CallClient.
  2. 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). ChannelClient over QUIC does not unblock AlknetClient because it's the same transport shape as CallClient.

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:

  • ChannelClient gives the channels crate a symmetric client/server pair, matching the call protocol's CallAdapter/CallClient shape.
  • Bidirectionality is preserved — the client can both initiate and receive channel/open.
  • The AlknetClient deferral (OQ-55) is not blocked by ChannelClient — they are independent concerns. ChannelClient builds standalone; the core extraction happens later when the blocker clears.

Negative:

  • ChannelClient duplicates ~20 lines of verifier-selection boilerplate from CallClient. This is the known cost of not extracting AlknetClient yet (OQ-55). Acceptable until the second transport's client exists.
  • ChannelClient is QUIC-only. A non-QUIC channels client (e.g., a browser over WebTransport) builds separately until AlknetClient is extracted. This is the same posture as CallClient and 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_channel sends channel/open)
  • ADR-074: ChannelBidiStreamSource (what Channel.source wraps)
  • ADR-075: ChannelManager (the shared state ChannelClient holds)
  • 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 shape ChannelClient mirrors)