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.
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 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
|
|
|
|
```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`.
|
|
///
|
|
/// 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_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 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](decisions/045-alknetclient-native-dial-seam.md) for the
|
|
full decision.
|
|
|
|
## 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 the composition layer's dial helper (ADR-045) |
|
|
| [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
|
|
|
|
- **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](client-and-adapters.md) — `CallClient` (the
|
|
shape `ChannelClient` mirrors) |