diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f9b70da..3d2e95c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -55,9 +55,10 @@ translates `channel/open` on channel 0 with `forwarded_for` — ADR-032; data channels byte-forwarded with `channel_id` rewrite; the hub never runs protocol-specific handlers), [ADR-080](decisions/080-channelclient.md) (`ChannelClient`, -QUIC-only initially, bidirectionality preserved; -`AlknetClient` core extraction stays deferred per OQ-55 — blocked on a -second *transport's* client, not a second client), +transport-agnostic `from_connection` primary + `connect_quic` convenience, +bidirectionality preserved; `AlknetClient` dial-seam extraction stays +deferred per OQ-55 — blocked on a second *transport's* dial, not a second +client), [ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate decomposition — `channels-core` (pure multiplexer, depends on alknet-core only, no call dependency) / `channels-call` (channel 0 pre-negotiation + @@ -177,7 +178,7 @@ adapter location map is now consistent: all HTTP-backed adapters | [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `into_sub_streams()` typed accessor, recursive composition | | [crates/channels/channels-adapter.md](crates/channels/channels-adapter.md) | draft | `ChannelsAdapter`, `ChannelManager`, demux/mux contracts (REQ-CH-01..04), two-pump pattern (ADR-078) | | [crates/channels/channel-operations.md](crates/channels/channel-operations.md) | draft | `channel/open`/`close`/`control`/`resources/subscribe`, ACL flow, `direction` semantics, hub relay contract (ADR-079) | -| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, QUIC-only initially, bidirectionality preserved | +| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary + `connect_quic` convenience, bidirectionality preserved | ## ADR Table diff --git a/docs/architecture/crates/channels/README.md b/docs/architecture/crates/channels/README.md index 36624eb..53e8445 100644 --- a/docs/architecture/crates/channels/README.md +++ b/docs/architecture/crates/channels/README.md @@ -24,7 +24,7 @@ protocol work itself. | [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition | | [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) | | [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) | -| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection, QUIC-only initially, bidirectionality preserved | +| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary, `connect_quic` convenience; bidirectionality preserved | ## Applicable ADRs @@ -39,7 +39,7 @@ protocol work itself. | [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope | | [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer | | [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite | -| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, QUIC-only; `AlknetClient` deferred (OQ-55) | +| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary + `connect_quic` convenience; `AlknetClient` dial-seam deferred (OQ-55) | | [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates | | [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements | | [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on | @@ -52,7 +52,7 @@ protocol work itself. | OQ | Title | Status | Relevance | |----|-------|--------|-----------| -| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | `ChannelClient` is decided (ADR-080); `AlknetClient` core extraction stays deferred — blocked on a second *transport's* client, not a second client | +| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary, `connect_quic` convenience. `AlknetClient` core extraction stays deferred — blocked on a second *transport's* dial (the shared dial+TLS seam), not a second client | | OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" | | OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) | diff --git a/docs/architecture/crates/channels/channel-client.md b/docs/architecture/crates/channels/channel-client.md index 9506a26..8ade2d2 100644 --- a/docs/architecture/crates/channels/channel-client.md +++ b/docs/architecture/crates/channels/channel-client.md @@ -12,7 +12,7 @@ specifies the API. `ChannelClient` is the symmetric counterpart to `ChannelsAdapter` (ADR-075). The server side is a `ProtocolHandler` (`ChannelsAdapter::handle`); the -client side dials a transport, establishes the channels connection, runs the +client side takes over an established transport `Connection`, runs the demux/mux, and exposes `open_channel(alpn, params) -> Channel` to the application. @@ -29,12 +29,37 @@ pub struct ChannelClient { } impl ChannelClient { - /// Open a channels connection to a peer. Dials the transport (QUIC - /// initially), establishes the channels connection, preinstalls - /// channel 0 (alknet/call), and returns the client. - pub async fn connect(addr: SocketAddr, credentials: CallCredentials) + /// Construct a `ChannelClient` over a pre-established transport + /// `Connection` on ALPN `alknet/channels`. This is the + /// transport-agnostic primary constructor: the caller (or a + /// transport-specific dial helper) produces the `Connection` — + /// via `Connection::from_stream`/`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 (`alknet/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-080). It must not be + /// coupled to a transport — the channels protocol is + /// transport-agnostic (ADR-071, ADR-065), and the client side is + /// half of that protocol. + pub async fn from_connection(connection: Connection) -> Result; + /// QUIC convenience constructor. Dials a QUIC connection to `addr` + /// on ALPN `alknet/channels` (using `credentials` for the TLS + /// handshake — ADR-034 verifier selection), then calls + /// `from_connection`. This is the "I just want QUIC" one-liner; + /// it is additive over `from_connection` and is a two-way door — + /// `connect_tcp_tls`, `connect_webtransport`, etc. can be added + /// alongside it without touching the one-way-door surface. + pub async fn connect_quic( + addr: SocketAddr, + credentials: CallCredentials, + ) -> Result; + /// Open a data channel with the given ALPN and params. Sends /// `channel/open` on channel 0, waits for the response, and returns /// the channel. @@ -87,14 +112,37 @@ pub struct ResourceEntry { } ``` -## QUIC-only initially +## Transport-agnostic by construction -`ChannelClient::connect` dials a QUIC connection (via the same `quinn` -endpoint `CallClient` uses) and wraps it as a channels connection. This is -the same transport shape as `CallClient`. When a second transport's client -exists (HTTP, TCP+TLS, WebTransport — per OQ-55), the dial can be -generalized. Until then, `ChannelClient` is QUIC-only — the same posture as -`CallClient`. +`ChannelClient` is the client side of the channels protocol. The channels +protocol is transport-agnostic (ADR-071 substrate modes; +`Connection::from_stream`/`from_bidi`/`from_source` from ADR-065/070 take +any `AsyncRead + AsyncWrite`). The client side must not be welded to a +transport — that would repeat the server-side welding ADR-065 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_stream`, a WebSocket carrying `alknet/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. + +`connect_quic(addr, credentials)` is a **convenience** constructor — 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. + +The credential/verifier-selection rule (ADR-034) lives in the transport's +own dial path, not in `from_connection` — `from_connection` receives an +already-established, already-authenticated `Connection`, exactly as +`ChannelsAdapter::handle` does on the server side. The ~20 lines of +verifier-selection boilerplate each dial helper rebuilds is the known +duplicated cost of not having `AlknetClient` (OQ-55) extracted yet; +`from_connection` keeps that boilerplate on the *transport-specific dial* +side, not on the channels protocol's one-way-door surface. ## Bidirectionality preserved @@ -113,18 +161,25 @@ request/response role. ## Relationship to `AlknetClient` (OQ-55 — deferred) -`ChannelClient` is a standalone client, not a specialization of a core -`AlknetClient`. The `AlknetClient` extraction (OQ-55) is genuinely deferred: -blocked on a second *transport's* client existing, not on a second client -existing. `ChannelClient` over QUIC is a second client but the same -transport shape as `CallClient` — it doesn't give enough information to -extract the transport-polymorphic dial seam. +`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-065 unwound on the server side. -When `AlknetClient` is eventually extracted (after a second transport's -client exists), `ChannelClient` and `CallClient` both refactor onto it. -Until then, they are independent clients with duplicated boilerplate (each -rebuilds verifier selection — ~20 lines). The friction is duplicated -boilerplate, not a missing capability. +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. ## Design Decisions @@ -132,12 +187,14 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/). | ADR | Decision | Summary | |-----|----------|---------| -| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) | +| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam deferred (OQ-55) | ## Open Questions -- **OQ-55** (deferred(scope)): `AlknetClient` core extraction — blocked on - a second *transport's* client. `ChannelClient` does not unblock it. +- **OQ-55** (deferred(scope)): `AlknetClient` core **dial+TLS seam** + extraction — blocked on a second *transport's* dial. `ChannelClient`'s + API is transport-agnostic (`from_connection`); the deferred part is the + shared *dial* across transports, not the channels protocol. ## References diff --git a/docs/architecture/crates/channels/overview.md b/docs/architecture/crates/channels/overview.md index de88c4a..606d0e8 100644 --- a/docs/architecture/crates/channels/overview.md +++ b/docs/architecture/crates/channels/overview.md @@ -249,7 +249,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/). | [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 | | [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level | | [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite | -| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) | +| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam deferred (OQ-55) | | [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers | ## Open Questions @@ -257,9 +257,10 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/). Open questions are tracked in [open-questions.md](../../open-questions.md). Key questions affecting this crate: -- **OQ-55** (deferred(scope)): `AlknetClient` core extraction — blocked on - a second *transport's* client, not a second client. `ChannelClient` is - decided (ADR-080). +- **OQ-55** (deferred(scope)): `AlknetClient` core **dial+TLS seam** + extraction — blocked on a second *transport's* dial. `ChannelClient`'s + API is transport-agnostic (`from_connection`); `AlknetClient` is the + shared *dial* across transports, not the channels protocol. - **OQ-56** (deferred(scope)): Full channel-level flow-control windowing — bounded-buffer is decided (ADR-076); full windowing is an extension blocked on a real HOL-blocking deployment observation. diff --git a/docs/architecture/crates/core/README.md b/docs/architecture/crates/core/README.md index 28ca49f..e53ed49 100644 --- a/docs/architecture/crates/core/README.md +++ b/docs/architecture/crates/core/README.md @@ -48,7 +48,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al | OQ-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity | | OQ-36 | Concrete persistence adapter shapes | resolved by ADR-035 | Read-sync / write-async split (`IdentityStore`); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `alknet-store-sqlite` crate | | OQ-37 | X.509 outgoing-only case | resolved by ADR-034 | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; client-side verifier by `PeerEntry` presence (CA vs fingerprint pin) | -| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | Blocked on a second *transport's* real client (not a second QUIC client); extracting a QUIC-shaped connector now would bake QUIC in as *the* establishment shape — the welding ADR-065 unwound on the server side | +| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | Blocked on a second *transport's* real **dial** (not a second QUIC dial); extracting a QUIC-shaped connector now would bake QUIC in as *the* establishment shape — the welding ADR-065 unwound on the server side. The client take-over APIs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection` — ADR-080) are transport-agnostic and decided; only the shared dial+TLS seam is deferred. | ## Key Design Principles diff --git a/docs/architecture/decisions/080-channelclient.md b/docs/architecture/decisions/080-channelclient.md index 93206e1..19a2ca5 100644 --- a/docs/architecture/decisions/080-channelclient.md +++ b/docs/architecture/decisions/080-channelclient.md @@ -2,30 +2,81 @@ ## Status -Accepted +Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API" below) + +## 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-065 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-071 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-075). The client side -needs a symmetric type — `ChannelClient` — that opens a transport, 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. +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`. -2. **`AlknetClient` (core, transport-polymorphic):** a general downstream- - facing client that crates use to connect to an alknet endpoint. Genuinely - deferred — blocked on a second *transport's* client existing (OQ-55 - tracks this correctly). `ChannelClient` over QUIC does not unblock - `AlknetClient` because it's the same transport shape as `CallClient`. + 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. #2 stays deferred per OQ-55. +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 @@ -39,12 +90,27 @@ pub struct ChannelClient { } impl ChannelClient { - /// Open a channels connection to a peer. Dials the transport (QUIC - /// initially), establishes the channels connection, preinstalls channel - /// 0 (alknet/call), and returns the client. - pub async fn connect(addr: SocketAddr, credentials: CallCredentials) + /// 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-071, + /// ADR-065). + pub async fn from_connection(connection: Connection) -> Result; + /// 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. + pub async fn connect_quic( + addr: SocketAddr, + credentials: CallCredentials, + ) -> Result; + /// 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. @@ -56,6 +122,12 @@ impl ChannelClient { direction: ChannelDirection, ) -> Result; + /// Subscribe to the peer's resource updates. Returns a stream of + /// resource-set events (ADR-073 channel/resources/subscribe). Part of + /// the one-way-door handler-facing surface (see Door type below). + pub async fn subscribe_resources(&self) + -> Result, 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; @@ -70,14 +142,26 @@ pub struct Channel { } ``` -### QUIC-only initially +### Transport-agnostic by construction -`ChannelClient::connect` dials a QUIC connection (via the same `quinn` -endpoint `CallClient` uses) and wraps it as a channels connection. This is -the same transport shape as `CallClient`. When a second transport's client -exists (HTTP, TCP+TLS, WebTransport — per OQ-55), the dial can be -generalized. Until then, `ChannelClient` is QUIC-only — the same posture as -`CallClient`. +`ChannelClient` is the client side of the channels protocol, which is +transport-agnostic (ADR-071 substrate modes; ADR-065 `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-065 +made. Welding the client's one-way-door API to QUIC would repeat the +welding ADR-065 explicitly unwound. ### Bidirectionality preserved @@ -97,20 +181,25 @@ not a request/response role. ### Relationship to `AlknetClient` (OQ-55 — deferred) -`ChannelClient` is a standalone client, not a specialization of a core -`AlknetClient`. The `AlknetClient` extraction (OQ-55) is genuinely deferred: -blocked on a second *transport's* client existing, not on a second client -existing. `ChannelClient` over QUIC is a second client but the same -transport shape as `CallClient` — it doesn't give enough information to -extract the transport-polymorphic dial seam. Extracting a QUIC-shaped -connector to core and naming it `AlknetClient` would bake QUIC in as *the* +`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-065 unwound on the server side. -When `AlknetClient` is eventually extracted (after a second transport's -client exists), `ChannelClient` and `CallClient` both refactor onto it. -Until then, they are independent clients with duplicated boilerplate (each -rebuilds verifier selection — ~20 lines). The friction is duplicated -boilerplate, not a missing capability. +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 @@ -124,24 +213,31 @@ boilerplate, not a missing capability. core extraction happens later when the blocker clears. **Negative:** -- `ChannelClient` duplicates ~20 lines of verifier-selection boilerplate - from `CallClient`. This is the known cost of not extracting `AlknetClient` - yet (OQ-55). Acceptable until the second transport's client exists. -- `ChannelClient` is QUIC-only. A non-QUIC channels client (e.g., a browser - over WebTransport) builds separately until `AlknetClient` is extracted. - This is the same posture as `CallClient` and is not a channels-specific - limitation. +- 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::connect` / `open_channel` / `call` / -`subscribe_resources` API is the handler-facing surface; changing it after -consumers exist is a rewrite. +**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. +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 diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 7007a1f..401e2ba 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -240,7 +240,19 @@ filtering the tables above. ### OQ-55: AlknetClient / Client Establishment Extraction -- **Blocked on**: a **second transport's** real client existing (not just a second QUIC client). The dial is transport-specific (QUIC, HTTP, TCP+TLS, WebTransport, raw TCP); we have one shape implemented (QUIC). Extracting a QUIC-shaped connector now would bake QUIC in as *the* establishment shape — the same welding ADR-065 unwound on the server side. The blocking condition is met when a non-QUIC client (SSH raw-TCP, HTTP-wrapped call) exists, so the transport-polymorphic seam is extractable from two *different* transport implementations. `ChannelClient` over QUIC does not unblock this — it's the same transport shape. +- **Blocked on**: a **second transport's** real dial existing (not just a + second QUIC dial). The dial is transport-specific (QUIC, HTTP, TCP+TLS, + WebTransport, raw TCP); we have one shape implemented (QUIC — + `CallClient::connect` and `ChannelClient::connect_quic`). Extracting a + QUIC-shaped connector now would bake QUIC in as *the* establishment + shape — the same welding ADR-065 unwound on the server side. The blocking + condition is met when a non-QUIC dial (SSH raw-TCP, HTTP-wrapped call, + TCP+TLS) exists, so the transport-polymorphic dial+TLS seam is + extractable from two *different* transport implementations. Note: the + *client APIs* are already transport-agnostic — `CallClient::spawn_dispatch` + and `ChannelClient::from_connection` (ADR-080) take a pre-established + `Connection`. What is deferred is the shared *dial*, not the client + protocol surface. - **Priority**: medium - **Full file**: [OQ-55](questions/055-alknetclient-establishment-extraction.md) diff --git a/docs/architecture/questions/055-alknetclient-establishment-extraction.md b/docs/architecture/questions/055-alknetclient-establishment-extraction.md index 86e1a22..30c3e92 100644 --- a/docs/architecture/questions/055-alknetclient-establishment-extraction.md +++ b/docs/architecture/questions/055-alknetclient-establishment-extraction.md @@ -8,14 +8,17 @@ - **Status**: deferred(scope) - **Door type**: two-way - **Priority**: medium -- **Blocked on**: a **second transport's** real client existing, not just a - second QUIC client. The blocking condition is met when, e.g., the SSH - crate's raw-TCP client or the HTTP-wrapped call client exists — so the - transport-polymorphic establishment seam is extractable from two +- **Blocked on**: a **second transport's** real dial existing, not just a + second QUIC dial. The blocking condition is met when, e.g., the SSH + crate's raw-TCP dial or the HTTP-wrapped call dial exists — so the + transport-polymorphic dial+TLS seam is extractable from two *different* transport implementations, not two QUIC variants. - `ChannelClient` over QUIC does not unblock this; it is a second *client* - but the same *transport shape*. The deferral is on transport-polymorphism, - not on client count. + `ChannelClient`'s `connect_quic` does not unblock this; it is a second + *client* but the same *transport shape*. `ChannelClient`'s + `from_connection` (the transport-agnostic take-over, ADR-080) is decided + and is not the thing being deferred — the shared *dial* is. The deferral + is on transport-polymorphism of the dial, not on client count or on the + channels protocol's API. - **Resolution**: Not yet decidable. The shared substance across TLS-carrying transports is ADR-034's verifier-selection rule (PeerEntry presence → fingerprint pin : CA-verify / fail-closed) and the @@ -23,25 +26,28 @@ rule. But the *dial* is transport-specific, and we have one of ~5 shapes implemented (QUIC; the others being HTTP, TCP+TLS, WebTransport, raw TCP). Extracting a QUIC-shaped connector to core and naming it - `AlknetConnector` would bake QUIC in as *the* establishment shape — the + `AlknetClient` would bake QUIC in as *the* establishment shape — the same welding ADR-065 unwound on the server side, repeated on the client side. The dial is transport-polymorphic; the shared rule is narrow. Until - a second transport's client exists, the seam between "dial + TLS" (per- + a second transport's dial exists, the seam between "dial + TLS" (per- transport) and "spawn the dispatcher" (per-crate) is not extractable from two real shapes — it's guessable from one. - **What does NOT block on this**: each crate building its own client - standalone. `CallClient` stays QUIC-only; `ChannelClient` will be - QUIC-only initially; the SSH crate's TCP client builds standalone; the - HTTP call client builds standalone. Core already permits all of this — - `Connection::from_stream` / `from_bidi` (ADR-065) handles the non-QUIC - transport on the server side, and nothing prevents a client from - constructing a `Connection` the same way after its own transport-specific - dial. The friction is duplicated boilerplate (each client rebuilds - verifier selection), not a missing capability. The bidirectionality + standalone, and each transport-specific dial helper. `CallClient`'s + transport-agnostic take-over (`spawn_dispatch`) and `ChannelClient`'s + transport-agnostic take-over (`from_connection`, ADR-080) are decided; + `connect_quic` dials QUIC and calls `from_connection`. The SSH crate's + TCP client, the HTTP call client, a `connect_tcp_tls` / `connect_webtransport` helper — each builds its own dial standalone. Core + already permits all of this — `Connection::from_stream` / `from_bidi` + (ADR-065) handles the non-QUIC transport on the server side, and nothing + prevents a client from constructing a `Connection` the same way after + its own transport-specific dial. The friction is duplicated boilerplate + (each dial helper rebuilds verifier selection), not a missing + capability and not a QUIC-welded client API. The bidirectionality criterion (a crate needs a Client type when (a) the endpoint has protocol-level authority — e.g., channels' id allocation — or (b) the protocol needs a reliable establishment interface) is met by each crate - independently; `AlknetClient` is the eventual *shared* establishment seam, + independently; `AlknetClient` is the eventual *shared dial+TLS seam*, not a prerequisite for any single client to exist. - **Cross-references**: ADR-034 (verifier selection — currently in `CallClient`, would move to the extracted seam when this is resolved),