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).
300 lines
15 KiB
Markdown
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) |