Files
alkcall/docs/architecture/channel-client.md
T
deepseek-v4-pro 08e7df2aa0 feat: rename ALPN prefix from alknet/ to alk/ (v0.1.1)
- CHANNELS_ALPN: b"alknet/channels" → b"alk/channels"
- CallAdapter::alpn(): b"alknet/call" → b"alk/call"
- derive_alpn_from_op_name: alknet/ prefix → alk/ prefix
- All ALPN string literals in src/ and docs/ updated
- ADR-004 amended with prefix rename rationale
- AGENTS.md, README.md updated
- Version bumped to 0.1.1

Review: docs/reviews/003-alpn-prefix-rename.md

Verification:
- cargo test: 542 passed, 0 failed
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
2026-08-14 13:55:28 +00:00

190 lines
8.8 KiB
Markdown

---
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<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`.
pub async fn open_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream), String>;
/// 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).
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.
## 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)