--- status: draft last_updated: 2026-07-18 --- # channel-client.md — ChannelClient The client side of a channels connection. ADR-043 is the decision; this doc specifies the API. ## What `ChannelClient` is the symmetric counterpart to `ChannelsAdapter` (ADR-039). 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 ```rust 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 `alk/channels`. This is the /// transport-agnostic primary constructor: the caller (or a /// transport-specific dial helper) produces the `Connection` — /// via `Connection::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 (`alk/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-043). It must not be /// coupled to a transport — the channels protocol is /// transport-agnostic (ADR-034, as amended by ADR-035; ADR-007, /// ADR-009), and the client side is half of that protocol. pub async fn from_connection(connection: Connection) -> Result; /// Call a per-ALPN open op (`channels//sub` or /// `channels//pub`) on channel 0. Returns the /// `ResponseEnvelope` (which carries `channel_id` on success). /// In single-stream call mode (ADR-036 amendment), this writes /// `call.requested` through channel 0's shared frame writer and /// awaits the response via the `PendingRequestMap`. pub async fn call_open_op(&self, operation_id: &str, input: Value) -> ResponseEnvelope; /// Open a data channel by calling the per-ALPN open op on channel 0 /// and adopting the resulting `channel_id` (ADR-047 §5 odd/even /// split). The connect side calls the open op; the accept side /// allocates the `channel_id` (even). The connect side then adopts /// the `channel_id` via `ChannelManager::adopt_channel` to install /// local routing state. /// /// Returns the `channel_id`, the `MpscSendStream` (write half), and /// the `MpscRecvStream` (read half). The caller can build a /// `Connection` from these via `channel_source` and /// `Connection::from_source`. /// /// The error is typed (ADR-049 §4 — review 006 N-1): /// `ChannelOpenError::CallFailed` carries the wire `CallError` /// verbatim (branch on `establishment_reason()` for /// `channel:open_failed`'s `details.reason`); `MissingChannelId` /// covers a malformed success reply; `AdoptFailed` covers local /// adoption failure. pub async fn open_channel( &self, operation_id: &str, input: Value, alpn: &str, ) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError>; /// Take the `CallConnection` — used by the consumer to register /// imported ops (`from_call`) on the connection's overlay. After /// this, `call_open_op` returns an error (the connection is owned /// by the consumer). pub async fn take_call_connection(&self) -> Option; } ``` ### Post-047: per-ALPN open ops, not a generic `channel/open` ADR-047 dissolved the generic `channel/open` operation into per-ALPN open ops (`channels//sub`, `channels//pub`). Each ALPN crate registers its own open op via `ChannelCore::register_openable` (ADR-047 §3, as amended 2026-08-13 — per-connection registration), optionally with an establisher via `register_openable_with_establisher` (ADR-049 — the awaited, bounded establishment phase; establishment failure resolves `Err` on the client with `channel:open_failed` + `details.reason`). The `ChannelClient` calls these ops by name on channel 0 via `call_open_op`; `open_channel` wraps `call_open_op` + `adopt_channel`. The pre-047 `ChannelDirection`, `Channel { channel_id, source }` struct, and `subscribe_resources` method are deferred (OQ-40, OQ-41). The `ResourceEntry.access` preview was dropped by ADR-047 §6 — it is available via `services/schema` on the op spec. ## The early-arrival park bound (push-first producers) The open-op response carrying `channel_id` races the producer's first data-plane writes: the producer's handler can start pumping before the consumer's `adopt_channel` runs. The demux parks those first chunks per-channel (FIFO) instead of dropping them, and `adopt_channel` drains the parked chunks into the new receiver — a push-first producer (a TTY backend's banner, a sub protocol's greeting) must not lose its first chunks. The park is bounded: **up to 64 chunks per channel** are parked (`EARLY_ARRIVAL_CAP`, ADR-040's memory bounds); chunks arriving past the cap are dropped silently — at the consumer this presents as a truncated stream with clean framing everywhere else, not as an error. Consumer-facing consequences (review 006 E-04, noted for tunnel-style consumers): - Size any pre-adopt buffering math (e.g. a UDP POC's MTU-vs-buffer sizing) against **64 parked chunks as the observable bound**, and adopt promptly — the open reply resolves *before* the first data arrives by design, so the adopt is the consumer's next step, not a slow path. - The producer side observes both halves of the race via `ChannelManager::early_arrival_count()` (chunks parked) and `ChannelManager::dropped_unknown_chunks()` (chunks lost to the cap) — a non-zero dropped counter under a push-first producer means the adopter was too slow for the producer's burst, and the affected streams were truncated at the park boundary. ## Transport-agnostic by construction `ChannelClient` is the client side of the channels protocol. The channels protocol is transport-agnostic (ADR-034 substrate modes, as amended by ADR-035; `Connection::from_bidi`/`from_source` from ADR-007/070/092 take any `AsyncRead + AsyncWrite`). The client side must not be welded to a transport — that would repeat the server-side welding ADR-007 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_bidi`, a WebSocket carrying `alk/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. The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`, ADR-045), not on `ChannelClient`. Callers compose `AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?` — two lines, the dial then the take-over. Keeping the dial off `ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`; the protocol crates are parallel to the dial, not downstream of it. The credential/verifier-selection rule (ADR-034) lives in the dial (`AlknetClient`), not in `from_connection` — `from_connection` receives an already-established, already-authenticated `Connection`, exactly as `ChannelsAdapter::handle` does on the server side. ## Bidirectionality preserved The channels protocol is bidirectional — either side can open a channel (ADR-037 §direction semantics). The connection owner (the side that holds the `ChannelManager`) allocates `channel_id`s (ADR-047 §5). The connect side calls per-ALPN open ops on channel 0; the accept side allocates the `channel_id` and responds. Both sides can initiate — the connect side calls the open op, the accept side could also call open ops on the connect side's channel 0 (if the connect side registers any). `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` `ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a pre-established `Connection`. The shared *dial+TLS* seam (`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which provides `AlknetClient` with three dial methods (`dial_quic` / `dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that `from_connection` consumes. The dial is transport-specific (QUIC, TCP+TLS, iroh); the take-over (`from_connection`) is transport-agnostic. The two concerns are separated. `AlknetClient::dial_quic` is the dial that feeds `from_connection`. A caller that needs transport selection (QUIC with TCP+TLS fallback) uses `AlknetClient` directly; the fallback policy is a caller concern. See [ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md) (resolved). ## Design Decisions All design decisions are documented as ADRs in [decisions/](decisions/). | ADR | Decision | Summary | |-----|----------|---------| | [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) | | [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing | ## Open Questions - **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam** — `alknet-client` with three dial methods. `ChannelClient`'s API is transport-agnostic (`from_connection`); the dial is the shared seam. See [ADR-045](decisions/045-alknetclient-native-dial-seam.md). ## References - ADR-043: ChannelClient (the decision) - ADR-035: channels pure channel multiplexing (no `stream_types`) - ADR-037: channel lifecycle operations (`open_channel` sends per-ALPN open ops) - ADR-038: ChannelBidiStreamSource (what `Channel.source` wraps, as amended by ADR-035 — `accept_bi` yields a `BiStream`) - ADR-039: ChannelManager (the shared state `ChannelClient` holds) - ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve the generic `channel/open`) - OQ-55: AlknetClient / client establishment extraction - `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the shape `ChannelClient` mirrors)