Files
alkcall/docs/architecture/channel-client.md
T
glm-5.3-flash 5f019c02f1 docs: re-key architecture docs off the alknet mono-repo — speak as alkcall
The ported docs still framed alknet siblings (alknet-core/-call/-channels/
-http/-tls/-client) as live load-bearing context of this crate. With the
extraction complete and alknet headed for glue-level replacement, the docs
now speak as alkcall and cite the real sibling crates (alkhttp, alktls,
alktty, alktunnels, alktrader).

- ADR-054 (new): re-own the verifier-selection rule in-crate — three
  remote roles (public X.509 endpoint / transport relay / hub),
  ServerCertVerifier by PeerEntry presence, RemoteIdentity None
  load-bearing. Provenance: alknet ADR-034 (never ported), demoted to
  footnote. Resolves OQ-21's cross-repo authority gap.
- OQ-43 (new, deferred(scope)): composition-layer ownership and shape
  after the alknet replacement — the named receiver for the
  downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
  substrate, OQ-42 hub broker, OQ-19 persistence adapters, ADR-045 dial
  seam). README layering diagram redrawn with the composition layer as
  TBD-ownership instead of "alknet / alknode" in concrete.
- Spec docs reframed: titles, self-references, dial/TLS/from_jsonschema
  homes, malformed legacy cite rows removed; dead relative research
  paths qualified as source-repo paths.
- ADR amendments (append-only): 027 (alknet-http -> alkhttp), 031 (post-
  extraction state), 044 (split landed as modules in alkcall), 045
  (name normalization; citation-space note).
- Citation hygiene: unprefixed/colliding source-repo cites prefixed
  ("alknet ADR-NNN (provenance)") across 009/011/022/034/035/038/040/
  043/044/045; stale OQ cites corrected (session registries OQ-04, batch
  semantics OQ-03, verifier rule ADR-054); stale anchors fixed.
- README provenance note consolidated; citation conventions documented.

Verification: markdown link check (all local links resolve, all in-crate
ADR cites point at existing decisions/ files); table integrity check
(uniform row widths across open-questions.md and edited tables); residual
alknet-mention sweep (only provenance/historical/intentional framing
remains); architecture review subagent pass with zero remaining criticals.
Docs-only change — no code paths touched.
2026-10-13 10:53:47 +00:00

11 KiB

status, last_updated
status last_updated
draft 2026-10-13 (alkcall reframing)

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

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<Self, ChannelError>;

    /// Call a per-ALPN open op (`channels/<alpn>/sub` or
    /// `channels/<alpn>/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<CallConnection>;
}

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/<alpn>/sub, channels/<alpn>/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 alknet ADR-044 — websocket-native session, provenance) — 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 the composition layer's dial helper, AlknetClient (today in the alknet glue; future owner OQ-43 — 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 alkcall depending on a dial crate; the protocol crates are parallel to the dial, not downstream of it.

The credential/verifier-selection rule (ADR-054, re-owned from the source repo's alknet 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_ids (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 the composition layer's dial helper

ChannelClient's API is transport-agnostic — from_connection takes a pre-established Connection. The shared dial+TLS seam is the composition layer's dial helper, AlknetClient (ADR-045; today in the alknet glue, future owner OQ-43), which provides 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 the dial helper directly; the fallback policy is a caller concern. See ADR-045 for the full decision.

Design Decisions

All design decisions are documented as ADRs in decisions/.

ADR Decision Summary
043 ChannelClient Client side; transport-agnostic from_connection primary; dial lives in the composition layer's dial helper (ADR-045)
035 channels Pure Channel Multiplexing No stream_types on open_channel/Channel; handler owns sub-stream multiplexing

Open Questions

  • Dial seam ownership (ADR-045): the dial helper (AlknetClient) lives in the composition layer today; its post-alknet owner and shape is OQ-43. ChannelClient's API is transport-agnostic (from_connection); the dial is the shared seam.

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)
  • ADR-045: the dial seam (dial out of the protocol crate)
  • client-and-adapters.md — CallClient (the shape ChannelClient mirrors)