Files
alknet/docs/architecture/crates/channels/channel-client.md
T
glm-5.2 73c621bf21 docs(arch): unweld ChannelClient from QUIC — from_connection primary, connect_quic convenience (ADR-080 amendment)
The channels protocol is transport-agnostic by design (ADR-071 substrate
modes; ADR-065 unwound the server-side QUIC-welding via
Connection::from_stream/from_bidi). ADR-080 had welded the client-side
one-way-door API to QUIC and masked it as a deferral ('QUIC-only
initially', 'can be generalized later') — anti-patterns #8/#9/#11
(door-type-as-deferral + resolved-with-escape-hatch). 'Can be
generalized later' meant 'can be rewritten later' — the expensive
reversal the one-way-door classification exists to prevent.

Fix: split the constructor surface.
- from_connection(connection: Connection) — transport-agnostic primary,
  one-way door. Mirrors server-side ChannelsAdapter::handle(Connection)
  and the existing CallClient::spawn_dispatch pattern.
- connect_quic(addr, credentials) — QUIC convenience, two-way door,
  additive. Future connect_tcp_tls / connect_webtransport join it
  without touching the one-way-door surface.

OQ-55 reframed: the deferred thing is the shared dial+TLS seam
(AlknetClient), not a QUIC-welded client API. The client take-over APIs
(CallClient::spawn_dispatch, ChannelClient::from_connection) are
transport-agnostic and decided; only the shared dial across transports
is blocked on a second transport's dial existing.

Files:
- decisions/080-channelclient.md: amendment section, Decision code
  block, Transport-agnostic by construction (replaces QUIC-only
  initially), Consequences, Door type, subscribe_resources added to
  Decision block (was referenced by Door type but missing)
- crates/channels/channel-client.md: from_connection primary API,
  Transport-agnostic by construction section, OQ-55 relationship
- crates/channels/overview.md, crates/channels/README.md,
  crates/core/README.md, README.md, open-questions.md,
  questions/055-...md: cross-reference summaries aligned
2026-07-12 19:33:36 +00:00

9.1 KiB

status, last_updated
status last_updated
draft 2026-07-12

channel-client.md — ChannelClient

The client side of a channels connection. ADR-080 is the decision; this doc specifies the API.

What

ChannelClient is the symmetric counterpart to ChannelsAdapter (ADR-075). The server side is a ProtocolHandler (ChannelsAdapter::handle); the client side takes over an established transport Connection, 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.

API

pub struct ChannelClient {
    manager: ChannelManager,
    // The transport-side demux/mux, running in a background task.
    ...
}

impl ChannelClient {
    /// Construct a `ChannelClient` over a pre-established transport
    /// `Connection` on ALPN `alknet/channels`. This is the
    /// transport-agnostic primary constructor: the caller (or a
    /// transport-specific dial helper) produces the `Connection` —
    /// via `Connection::from_stream`/`from_bidi` (TCP+TLS,
    /// WebTransport, SSH `direct-tcpip`), a quinn connection, or any
    /// other `AsyncRead + AsyncWrite` source — and this method takes
    /// over: installs channel 0 (`alknet/call`), spawns the demux/mux,
    /// and returns the client. Mirrors the server side's
    /// transport-agnostic `ChannelsAdapter::handle(Connection)` and
    /// `CallClient::spawn_dispatch(Connection)`.
    ///
    /// This is the one-way-door API surface (ADR-080). It must not be
    /// coupled to a transport — the channels protocol is
    /// transport-agnostic (ADR-071, ADR-065), and the client side is
    /// half of that protocol.
    pub async fn from_connection(connection: Connection)
        -> Result<Self, ChannelError>;

    /// QUIC convenience constructor. Dials a QUIC connection to `addr`
    /// on ALPN `alknet/channels` (using `credentials` for the TLS
    /// handshake — ADR-034 verifier selection), then calls
    /// `from_connection`. This is the "I just want QUIC" one-liner;
    /// it is additive over `from_connection` and is a two-way door —
    /// `connect_tcp_tls`, `connect_webtransport`, etc. can be added
    /// alongside it without touching the one-way-door surface.
    pub async fn connect_quic(
        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.
    pub async fn open_channel(
        &self,
        alpn: &str,
        stream_types: &[u8],
        params: Value,
        direction: ChannelDirection,
    ) -> Result<Channel, ChannelError>;

    /// Subscribe to the peer's resource updates. Returns a stream of
    /// resource-set events (ADR-073 channel/resources/subscribe). Each
    /// event carries the JSON `output.resources` array from ADR-073's
    /// `channel/resources/subscribe` response shape.
    pub async fn subscribe_resources(&self)
        -> Result<BoxStream<ResourceEvent>, 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 enum ChannelDirection {
    InitiatorToResponder,
    ResponderToInitiator,
}

pub struct Channel {
    pub channel_id: u32,
    pub stream_types: Vec<u8>,
    /// The sub-streams, accessible via accept_bi() (ADR-074 generic path)
    /// or into_sub_streams() (ADR-074 typed path).
    pub source: ChannelBidiStreamSource,
}

/// One event from `channel/resources/subscribe`. Wraps the JSON `output`
/// object from ADR-073's subscribe response — the `resources` array
/// describing what ALPNs the peer exposes and with what `access` preview.
/// The channels crate maps the JSON to this typed struct; the fields mirror
/// ADR-073's response shape.
pub struct ResourceEvent {
    pub resources: Vec<ResourceEntry>,
}

pub struct ResourceEntry {
    pub alpn: String,
    pub backends_or_targets: Vec<String>, // ALPN-specific enumeration
    pub access: Value,                     // preview of AccessControl (advisory)
}

Transport-agnostic by construction

ChannelClient is the client side of the channels protocol. The channels protocol is transport-agnostic (ADR-071 substrate modes; Connection::from_stream/from_bidi/from_source from ADR-065/070 take any AsyncRead + AsyncWrite). The client side must not be welded to a transport — that would repeat the server-side welding ADR-065 explicitly unwound.

from_connection(connection: Connection) is the primary constructor and the one-way-door API surface. It takes a pre-established Connection and takes over channels establishment. The transport is the caller's concern: Connection::from_bidi(tls_stream, ...) for TCP+TLS, a quinn Connection, a WebTransport BiStream, an SSH direct-tcpip channel wrapped via from_stream, a WebSocket carrying alknet/channels (the browser path per 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.

connect_quic(addr, credentials) is a convenience constructor — dial QUIC, then from_connection. It is additive and two-way-door: transport-specific dial helpers (connect_tcp_tls, connect_webtransport, …) join it as transports are added, none of which touch the from_connection contract. The dial helper set is open-ended by design.

The credential/verifier-selection rule (ADR-034) lives in the transport's own dial path, not in from_connection — from_connection receives an already-established, already-authenticated Connection, exactly as ChannelsAdapter::handle does on the server side. The ~20 lines of verifier-selection boilerplate each dial helper rebuilds is the known duplicated cost of not having AlknetClient (OQ-55) extracted yet; from_connection keeps that boilerplate on the transport-specific dial side, not on the channels protocol's one-way-door surface.

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 request/response sense — 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).

ChannelClient is one endpoint of a bidirectional channels connection. The name follows the CallClient convention (the side that dialed), not a request/response role.

Relationship to AlknetClient (OQ-55 — deferred)

ChannelClient's API is transport-agnostic — from_connection takes a pre-established Connection. What is deferred (OQ-55) is the shared dial+TLS seam (AlknetClient): the transport-specific work each dial helper does — open a socket, run the TLS handshake, apply ADR-034's verifier-selection rule, produce a Connection. That dial is genuinely transport-specific (QUIC, TCP+TLS, WebTransport, raw TCP, SSH), and we have one shape implemented (QUIC, in connect_quic). Extracting a QUIC-shaped connector now and naming it AlknetClient would bake QUIC in as the establishment shape — the same welding ADR-065 unwound on the server side.

This is why from_connection is the one-way-door surface and connect_quic is a two-way-door convenience over it. AlknetClient (when extracted, after a second transport's dial exists) becomes the shared dial; from_connection stays the shared channels-take-over. The two concerns are separated now, before the one-way-door API is cast.

The friction while AlknetClient is deferred is duplicated verifier-selection boilerplate across dial helpers (~20 lines each) — not duplicated capability and not a QUIC-welded client API.

Design Decisions

All design decisions are documented as ADRs in decisions/.

ADR Decision Summary
080 ChannelClient Client side; transport-agnostic from_connection primary, connect_quic convenience; AlknetClient dial-seam deferred (OQ-55)

Open Questions

  • OQ-55 (deferred(scope)): AlknetClient core dial+TLS seam extraction — blocked on a second transport's dial. ChannelClient's API is transport-agnostic (from_connection); the deferred part is the shared dial across transports, not the channels protocol.

References

  • ADR-080: ChannelClient (the decision)
  • 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
  • docs/architecture/crates/call/client-and-adapters.md — CallClient (the shape ChannelClient mirrors)