Files
alkcall/docs/architecture/decisions/043-channelclient.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

15 KiB

ADR-043: ChannelClient — the Client Side of a Channels Connection

Status

Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API" below; amended 2026-07-16 — connect_quic removed per ADR-045 §5, see "Amendment: connect_quic removed" below; amended 2026-07-18 by ADR-035 — stream_types field removed from open_channel and Channel; the channels layer has no stream_type concept, see "Amendment (ADR-035, 2026-07-18)" below)

Amendment (ADR-035, 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-035) — 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-009). 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-035 (amending ADR-038).

The body below describes the original (with stream_types) shape; the amendment above is the operative decision. See ADR-035 for the resolution rationale and the cross-ADR impacts.

Amendment: connect_quic removed (2026-07-16, per ADR-045 §5)

The connect_quic(addr, credentials) convenience constructor is removed. Keeping it as a thin wrapper over AlknetClient::dial_quic would make alknet-channels-call depend on alknet-client, contradicting the dep graph (the protocol crates are parallel to the dial, not downstream of it). Callers compose AlknetClient::dial_quic(...).await? + ChannelClient::from_connection(conn).await?. The from_connection primary constructor (the 2026-07-12 amendment below) is unchanged and remains the one-way-door surface. The connect_quic references in the body of this ADR are the historical shape; they do not survive into the implementation. See ADR-045 §5 for the full rationale and the breaking-change acknowledgment.

Amendment: transport-agnostic API (2026-07-12)

The original Decision named connect(addr: SocketAddr, credentials) as the primary constructor and framed it as "QUIC-only initially" — to be generalized when a second transport's client exists. That framing welded the client-side one-way-door API to QUIC, the same welding ADR-007 unwound on the server side, and masked it as a two-way-door deferral (anti-patterns #8, #9, #11). "Can be generalized later" meant "can be rewritten later" — the expensive reversal the one-way-door classification exists to prevent.

The channels protocol is transport-agnostic by design (ADR-034 substrate modes; Connection::from_stream/from_bidi/from_source accept any AsyncRead + AsyncWrite). The client side is half of that protocol and must not be coupled to a transport. This amendment splits the constructor surface:

  • from_connection(connection: Connection) — the transport-agnostic primary constructor and the one-way-door API. Takes a pre-established Connection (produced by any transport — TCP+TLS via from_bidi, WebTransport BiStream, SSH direct-tcpip, a quinn connection, a WebSocket carrying alknet/channels per ADR-044), installs channel 0, spawns the demux/mux, returns the client. Mirrors the server-side ChannelsAdapter::handle(Connection) (substrate-agnostic) and the existing CallClient::spawn_dispatch(Connection) pattern.
  • connect_quic(addr, credentials) — a QUIC convenience constructor: dial QUIC, then from_connection. Additive and two-way-door. connect_tcp_tls, connect_webtransport, etc. join it as transports are added, without touching the one-way-door surface.

The dial+TLS seam (the transport-specific work each dial helper does — verifier selection per ADR-034, handshake, produce a Connection) is the correct scope of OQ-55's deferral. AlknetClient is the eventual shared dial; from_connection is the shared channels-take-over. Separating them now, before the one-way-door API is cast, is the point — not a deferral. The "QUIC-only initially" framing is removed; it was the anti-pattern this amendment corrects.

The door-type classification is unchanged: from_connection is one-way (the handler-facing surface), connect_quic is two-way (additive convenience). The AlknetClient extraction remains deferred (OQ-55) — but what is deferred is the shared dial, not a QUIC-welded client API.

Context

Both sides of a channels connection do the demux/mux work. The server side is a ProtocolHandler (ChannelsAdapter::handle, ADR-039). The client side needs a symmetric type — ChannelClient — that 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.

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. The take-over half is transport-agnostic (from_connection); the dial half is transport-specific (connect_quic and future transport helpers).
  2. AlknetClient (core, transport-polymorphic): the shared dial+TLS seam — the transport-specific work (open socket, TLS handshake, ADR-034 verifier selection, produce a Connection) that each transport's dial helper rebuilds. Genuinely deferred — blocked on a second transport's dial existing (OQ-55 tracks this correctly). A single QUIC dial (connect_quic) does not give enough information to extract the transport-polymorphic dial seam; two different transport dials do.

This ADR decides #1 — ChannelClient, with from_connection as the transport-agnostic primary constructor and connect_quic as a transport- specific dial helper. #2 (the shared AlknetClient dial+TLS seam) 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 {
    /// Transport-agnostic primary constructor. Takes a pre-established
    /// `Connection` (any transport — TCP+TLS via `from_bidi`,
    /// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
    /// WebSocket per ADR-044), installs channel 0 (alknet/call), spawns
    /// the demux/mux, and returns the client. Mirrors the server-side
    /// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
    /// API surface — it must not be coupled to a transport (ADR-034,
    /// ADR-007).
    pub async fn from_connection(connection: Connection)
        -> Result<Self, ChannelError>;

    /// QUIC convenience constructor. Dials a QUIC connection to `addr`
    /// on ALPN `alknet/channels` (credentials → TLS handshake,
    /// ADR-034 verifier selection), then calls `from_connection`.
    /// Additive and two-way-door — `connect_tcp_tls`,
    /// `connect_webtransport`, etc. join it as transports are added.
    ///
    /// **REMOVED per ADR-045 §5.** The dial is extracted into
    /// `AlknetClient`; `connect_quic` is deleted, not delegated.
    /// Callers compose `AlknetClient::dial_quic` + `from_connection`.
    /// The `CallCredentials` parameter is moot — `CallCredentials` is
    /// removed per ADR-012 (amended 2026-07-17); the dial consumes
    /// `ConnectionCredentials` from `alknet-core`.
    pub async fn connect_quic(
        addr: SocketAddr,
        credentials: CallCredentials,  // REMOVED — CallCredentials is removed
    ) -> 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>;

    /// Subscribe to the peer's resource updates. Returns a stream of
    /// resource-set events (ADR-037 channel/resources/subscribe). Part of
    /// the one-way-door handler-facing surface (see Door type below).
    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 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-038.
    pub source: ChannelBidiStreamSource,
}

Transport-agnostic by construction

ChannelClient is the client side of the channels protocol, which is transport-agnostic (ADR-034 substrate modes; ADR-007 from_stream/from_bidi). The primary constructor — from_connection(connection: Connection) — takes a pre-established Connection from any transport and takes over channels establishment. This mirrors the server-side ChannelsAdapter::handle(Connection), which is substrate-agnostic by the same mechanism: the server receives a Connection (QUIC-native, TCP+TLS via from_bidi, WebTransport, SSH direct-tcpip, …) and runs the demux loop unchanged; the client receives a Connection the same way and runs the same logic from the dialing side.

connect_quic(addr, credentials) is a convenience over from_connection: 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.

This is the client-side analogue of the server-side generalization ADR-007 made. Welding the client's one-way-door API to QUIC would repeat the welding ADR-007 explicitly unwound.

Bidirectionality preserved

The channels protocol is bidirectional — either side can open a channel (ADR-037 §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'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-007 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.

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:

  • Each transport-specific dial helper duplicates ~20 lines of verifier-selection boilerplate (from connect_quic). This is the known cost of not extracting AlknetClient yet (OQ-55). Acceptable until the second transport's dial exists, at which point AlknetClient extracts the shared dial+TLS seam. The from_connection API — the one-way-door surface — is unaffected; only the dial helpers carry the duplication.

Door type

One-way. The ChannelClient::from_connection / open_channel / call / subscribe_resources API is the handler-facing surface; changing it after consumers exist is a rewrite. from_connection is the one-way-door primary constructor (transport-agnostic).

connect_quic (and future connect_tcp_tls / connect_webtransport / …) are two-way doors — additive convenience constructors over from_connection. Adding, removing, or changing a dial helper is cheap and does not touch the one-way-door surface.

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. What is deferred is the shared dial+TLS seam; from_connection's transport-agnostic contract is decided now.

References

  • ADR-037: channel lifecycle operations (open_channel sends channel/open)
  • ADR-038: ChannelBidiStreamSource (what Channel.source wraps, as amended by ADR-035 — accept_bi yields a BiStream)
  • ADR-035: channels pure channel multiplexing (stream_types field removed from open_channel and Channel; handler owns sub-stream multiplexing)
  • ADR-039: 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.mdCallClient (the shape ChannelClient mirrors)