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

300 lines
15 KiB
Markdown

# 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`
```rust
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.md``CallClient` (the
shape `ChannelClient` mirrors)