docs(arch): AlknetClient native dial seam — resolves OQ-55 (ADR-089)

Extract the deferred AlknetClient as a new crate alknet-client — the
client-side analogue of AlknetEndpoint. Three dial methods (QUIC +
TCP+TLS via TlsClientConfig, iroh via key) produce a Connection for
CallClient::spawn_dispatch / ChannelClient::from_connection to consume.
The deferral collapsed because ADR-086 gave the native endpoint type
three dial shapes within one endpoint type, ADR-087 broke the circular
hedge, and ADR-083 gave the server-side shape to mirror by symmetry.

Names the three concept layers that were tangled throughout the initial
development (deployment role / establishment side / ALPN-level category)
so the fix is legible. Names alknet/register as a dialable entry-point
ALPN (native registration, parallel to HTTP registration in OQ-58); its
wire protocol is deferred to OQ-66 (blocked on OQ-58's token model).

Cross-references updated across 11 existing docs (README, overview,
open-questions, OQ-55, tls, hub, core, channels README/overview/
channel-client, call client-and-adapters) to reflect OQ-55 resolved and
the new alknet-client crate. Architecture review passed (2 critical, 7
warnings — all addressed).
This commit is contained in:
glm-5.2 committed 2026-07-15 12:50:13 +00:00
1 parent 1291a751b0
commit ce7de57973
14 files changed
+1224 -182

No files matched your search

@@ -130,7 +130,9 @@ impl CallClient {
/// Feature-gated on `quinn` (the dial is QUIC-specific). Additive
/// and two-way-door — `connect_tcp_tls`, `connect_webtransport`,
/// etc. join it as transports are added, without touching the
/// `spawn_dispatch` contract.
/// `spawn_dispatch` contract. This convenience is a thin wrapper
/// over `AlknetClient::dial_quic` (ADR-089) — a caller that needs
/// transport selection uses `AlknetClient` directly.
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
+2 -2
View File
@@ -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`, transport-agnostic `from_connection` primary + `connect_quic` convenience; `AlknetClient` dial-seam 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 extracted (ADR-089, resolves 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`'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-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary, `connect_quic` convenience. `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| 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) |
@@ -159,27 +159,24 @@ populates what operations they expose).
name follows the `CallClient` convention (the side that dialed), not a
request/response role.
## Relationship to `AlknetClient` (OQ-55 — deferred)
## Relationship to `AlknetClient` (ADR-089 — resolved)
`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.
pre-established `Connection`. The shared *dial+TLS* seam
(`AlknetClient`, OQ-55) is now extracted: [`alknet-client`](../client/README.md)
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.
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.
`connect_quic` becomes a thin wrapper over `AlknetClient::dial_quic` —
dial QUIC, then `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-089](../../decisions/089-alknetclient-native-dial-seam.md) for the
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
(resolved).
## Design Decisions
@@ -187,14 +184,15 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam deferred (OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
## Open Questions
- **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.
- **OQ-55** (resolved by ADR-089): `AlknetClient` core **dial+TLS seam**
— extracted as `alknet-client` with three dial methods.
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
dial is the shared seam, now extracted. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
## References
@@ -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; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam deferred (OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam extracted (ADR-089, resolves 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,10 +257,11 @@ 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 **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-55** (resolved by ADR-089): `AlknetClient` core **dial+TLS seam**
— extracted as `alknet-client` with three dial methods.
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
dial is the shared seam, now extracted. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
- **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.
+549
View File
@@ -0,0 +1,549 @@
---
status: draft
last_updated: 2026-07-15
---
# alknet-client
The native client dial seam — the client-side analogue of
`AlknetEndpoint`. A multi-transport dialer that takes pre-built
transport handles (quinn, TCP+TLS, iroh), dials a remote `AlknetEndpoint`
on a chosen ALPN, and produces a `Connection` for the protocol
take-overs (`CallClient::spawn_dispatch`,
`ChannelClient::from_connection`) to consume. It is the Rust native
client; it does not run protocols, manage peer lifecycle, or supervise
reconnection. It dials and produces a `Connection`.
## What
`AlknetClient` is the dial. Before this crate, each protocol client
(`CallClient::connect`, `ChannelClient::connect_quic`) built its own
QUIC dial inline — building a `TlsClientConfig`, constructing a
`quinn::Endpoint`, calling `connect_with`, wrapping as a `Connection`.
The dial boilerplate was duplicated, and there was no place for a
second transport's dial (TCP+TLS, iroh) to live without each protocol
client growing its own per-transport dial helper.
`alknet-client` extracts the dial the same way ADR-083 extracted the
accept loop on the server side: one type that takes pre-built transport
handles and produces a `Connection`, with the transport choice as a
parameter. The protocol take-overs are unchanged — they consume the
`Connection` and do not know `AlknetClient` produced it.
### The three concept layers (ADR-089 §"The tangle this ADR also names")
Three concept levels were conflated throughout the initial development.
`AlknetClient` is the fix for one of them (the establishment side);
naming all three is what makes the fix legible.
| Layer | Concepts | What it determines |
|-------|----------|--------------------|
| **Deployment role** | Hub / Worker / Hub-Worker | Who accepts, who dials — which side(s) you instantiate |
| **Establishment side** | `AlknetEndpoint` (server) / `AlknetClient` (client) | Accept-and-resolve-identity vs. dial-and-present-identity |
| **ALPN-level category** | Endpoint ALPN / Entry-point ALPN (ADR-086 §2) | Whether identity is required at the TLS layer vs. per-request |
The layers are orthogonal. A **hub** uses an `AlknetEndpoint` (server)
AND an `AlknetClient` (client, when dialing workers). A **worker** uses
an `AlknetClient` (client) AND may use an `AlknetEndpoint` (server, if
it accepts inbound). The role determines which side(s) you instantiate,
not what the side IS. `AlknetClient` is the client-side establishment
type — Layer 2 — independent of the deployment role that uses it and of
the ALPN-level category of the ALPN it dials.
### What `AlknetClient` IS
The client-side analogue of `AlknetEndpoint`: a multi-transport dialer
that produces `Connection`s for the protocol take-overs to consume.
Narrowed to the **native case**: dialing native endpoint types (QUIC +
TCP+TLS, both rustls-consuming via `TlsClientConfig`; iroh as the
key-not-config exception) over the native ALPNs (`alknet/register`,
`alknet/call`, `alknet/channels`).
### What `AlknetClient` is NOT
- **Not a protocol implementation.** It does not run the call protocol
or the channels protocol. It produces a `Connection`;
`CallClient`/`ChannelClient` take over from there. Analogue:
`AlknetEndpoint` dispatches by ALPN; the handler runs the protocol.
- **Not a hub or worker.** Hub/Worker are deployment roles that *use*
`AlknetClient` (and `AlknetEndpoint`). `AlknetClient` has no peer
lifecycle, no aggregated env, no supervision loop, no relay. The
hub's `supervise_worker` takes a `dial` closure that can call
`AlknetClient` internally — the hub does not need to know
`AlknetClient` exists.
- **Not the web/browser client.** Browsers dial via WebSocket/HTTP
(ADR-044/048) — a different client surface (the JS SDK / wasm), not
`AlknetClient`. `AlknetClient` is the Rust native client.
- **Not a replacement for `CallClient`/`ChannelClient`.** Those are
the protocol take-overs. `AlknetClient` is the dial that feeds them.
## Why
The dial was deferred (OQ-55) because extracting a QUIC-shaped
connector would bake QUIC in as *the* establishment shape. ADR-089
resolves the deferral — three decisions (ADR-086, ADR-087, ADR-083)
collapsed the blocking conditions. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §"Why
the deferral has collapsed" for the full rationale.
## Architecture
### `AlknetClient`
The central type. Holds pre-built transport handles, all optional — the
client dials with whichever transport the remote endpoint type implies.
```rust
pub struct AlknetClient {
#[cfg(feature = "quinn")]
quinn: Option<quinn::Endpoint>,
#[cfg(feature = "tcp")]
tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
iroh: Option<iroh::Endpoint>,
}
impl AlknetClient {
pub fn new() -> Self;
#[cfg(feature = "quinn")]
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self;
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(mut self, connector: tokio_rustls::TlsConnector) -> Self;
#[cfg(feature = "iroh")]
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self;
}
```
The builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` /
`with_tcp_tls` (ADR-083) — the assembly layer builds the transport
handles and hands them to the client via builder methods. A native
client that needs QUIC-with-TCP+TLS-fallback holds both a quinn
endpoint and a TCP+TLS connector; a minimal iroh-only client holds only
the iroh endpoint.
### The three dials
```rust
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. The `server_name` is the
/// TLS SNI / name (for X.509; ignored for raw-key pinning).
/// Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
credentials: &CallCredentials,
) -> Result<Connection, ClientDialError>;
/// TCP+TLS dial. Builds a `TlsClientConfig` from `credentials`,
/// connects a `TcpStream` to `addr`, wraps with `TlsConnector`
/// using `host` as the SNI, returns a `Connection` via
/// `Connection::from_bidi` (ADR-065). Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
credentials: &CallCredentials,
) -> Result<Connection, ClientDialError>;
/// Iroh dial. Dials `node_id` on `alpn` via the iroh endpoint.
/// The iroh path does NOT use `TlsClientConfig` — iroh has its
/// own TLS (shares the `Ed25519SecretKey`, not the rustls config —
/// ADR-087 §3, ADR-089 §3). The verifier is iroh's `NodeId` match
/// (fingerprint pin by another name — ADR-034 §3). An unknown
/// iroh remote fails closed (no CA). Feature-gated on `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
node_id: iroh::NodeId,
alpn: &[u8],
local_key: &alknet_core::config::Ed25519SecretKey,
) -> Result<Connection, ClientDialError>;
}
```
The two rustls dials (`dial_quic`, `dial_tcp_tls`) share
`TlsClientConfig::new` — the ADR-034 verifier selection (fingerprint
pin for a known peer, CA-verify for an unknown X.509 remote, fail-closed
for an unknown raw-key remote) and the ADR-084 crypto provider
(`aws_lc_rs`). The iroh dial is the exception: iroh has its own TLS and
takes the `Ed25519SecretKey` directly, not a `rustls::ClientConfig`.
The consistency is in the rule (ADR-034), not in the type — the same
exception as the server side (ADR-082, ADR-087 §3).
### What the dial does NOT do
- **No protocol take-over.** The dial returns a `Connection`; the
caller hands it to `CallClient::spawn_dispatch` or
`ChannelClient::from_connection`. `AlknetClient` does not spawn the
dispatch loop or install channel 0.
- **No identity resolution.** The client *presents* its identity (via
the client cert in `TlsClientConfig`) and *verifies* the remote (via
the ADR-034 verifier). It does not resolve the remote's identity into
a `PeerId` — that happens inside the protocol take-over (the
`CallAdapter` / `CallConnection` resolves the fingerprint via
`IdentityProvider`).
- **No reconnection / supervision.** The dial is one-shot. A caller
that needs reconnect-with-backoff wraps the dial in a supervision
loop (the hub's `supervise_worker` pattern — a closure that produces
a `Connection`).
- **No transport fallback.** A caller that needs
QUIC-with-TCP+TLS-fallback dials QUIC, catches the error, and dials
TCP+TLS. `AlknetClient` provides both dials; the fallback policy is a
caller concern (or a future `dial_with_fallback` helper —
two-way-door).
### Credentials
`AlknetClient`'s dials take a `CallCredentials` bundle — the existing
type from `alknet-call` (the local `TlsIdentity`, the optional
`auth_token`, and the `RemoteIdentity` for verifier selection). The
credentials come from `Capabilities` (ADR-014), never from environment
variables — the no-env-vars invariant. The assembly layer derives them
from the vault at startup and passes them to each dial. The credential
type's crate location is a two-way-door detail — see
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §"What
this does NOT change" and the Dependencies section below.
### The dialable ALPNs
`AlknetClient` dials any ALPN the remote endpoint advertises. The
native ALPNs (ADR-086):
| ALPN | Category (ADR-086 §2) | Protocol take-over | Identity |
|------|----------------------|--------------------|----------|
| `alknet/register` | entry point | (registration handshake — deferred, OQ-66) | None at TLS; per-request token (or open) |
| `alknet/call` | endpoint | `CallClient::spawn_dispatch` | Fingerprint (raw key / client cert) or bearer token (first frame) |
| `alknet/channels` | endpoint | `ChannelClient::from_connection` | Fingerprint or bearer token (resolved on channel 0 — ADR-072) |
The dial is the same for all three — the difference is the protocol that
runs on the resulting `Connection`. For `alknet/register`, the protocol
is the registration handshake (deferred — see "`alknet/register`"
below). For `alknet/call` and `alknet/channels`, the protocol is the
call / channels take-over, which the caller invokes after the dial.
### `alknet/register`
The native registration entry point, parallel to HTTP registration
(OQ-58) but without the HTTP layer. A native worker that has no HTTP
client dials `alknet/register` directly. The connection is an **entry
point** (ADR-086 §2): accepted without an established peer identity,
authenticated per-request by the registration token (or open for
no-token registration). The dial produces the `Connection`; the
registration handshake runs on it.
Two registration cases (token / no-token), both hub concerns and both
optional — see [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md)
§6 for the full description.
The `alknet/register` **wire protocol** (the handshake on the
`Connection` — what frames the client sends, what the hub returns) ties
into the call crate's ACL and the OQ-58 enrollment model. It is
**deferred** to a dedicated ADR. This spec names the ALPN and its
entry-point role; the HTTP registration endpoint (OQ-58) remains the
first implementation. See OQ-66.
### Relationship to `CallClient` / `ChannelClient`
The dial produces a `Connection`; the protocol take-overs consume it:
```rust
// Dial QUIC, take over as channels:
let conn = client.dial_quic(addr, "alknet", b"alknet/channels", &creds).await?;
let channels = ChannelClient::from_connection(conn).await?;
// Dial TCP+TLS, take over as call:
let conn = client.dial_tcp_tls("hub.example", addr, b"alknet/call", &creds).await?;
let call = CallClient::new(registry, idp).spawn_dispatch(conn);
```
The existing convenience constructors (`CallClient::connect`,
`ChannelClient::connect_quic`) become thin wrappers over
`AlknetClient::dial_quic` — they build an ephemeral `AlknetClient` (or
accept one), dial QUIC, and call `spawn_dispatch` / `from_connection`.
They remain for the "I just want QUIC, no `AlknetClient` wiring" case.
A caller that needs transport selection (QUIC with TCP+TLS fallback)
uses `AlknetClient` directly. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §5.
### Iroh — shares the key, not the config (client side too)
The iroh client dial, like the iroh server side (ADR-082, ADR-087 §3),
does not consume a `rustls::ClientConfig`. It takes the
`Ed25519SecretKey` directly and feeds it to
`iroh::SecretKey::from_bytes`. Iroh handles TLS internally. The
verifier is iroh's `NodeId` match — the remote's `NodeId` (Ed25519
public key) is verified against the expected `NodeId`, which is
fingerprint-pinning by another name. An unknown iroh remote fails
closed (no CA to fall back to — ADR-034 §3, Assumption 1).
The `dial_iroh` method takes the `Ed25519SecretKey` as a parameter
rather than pulling it from `CallCredentials` because iroh does not use
the `TlsClientConfig` path. The assembly layer reads the key from
`StaticConfig` (in core) and passes it directly, same as the server
side's iroh endpoint construction.
### Non-Rust native clients (out of scope)
The wire protocols (channels 9-byte chunk format — ADR-071; call
`EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint
uses X.509 (the web endpoint type, or a native endpoint with X.509
instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python,
wasm) can negotiate TLS with standard library TLS stacks and implement
the wire protocols directly. A wasm implementation of the wire
protocols is reusable both in-browser and server-side (Deno, etc.),
reducing the need for per-language native adapters.
`AlknetClient` is the **Rust** native client — one of several possible
native clients sharing the same wire protocols. The non-Rust clients
are out of scope for this crate; they implement the wire protocols in
their own languages. The X.509 endpoint type is what makes this
possible — raw-key (RFC 7250) endpoints require a TLS stack that
supports raw public keys, which most non-Rust runtimes do not (browsers
definitely do not — ADR-086).
### `ClientDialError`
The error type for all three dial methods. A single
`#[non_exhaustive]` enum, one variant per failure category:
```rust
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientDialError {
/// TLS config construction (TlsClientConfig::new failure —
/// verifier build, cert load, provider init). Wraps `TlsError`
/// from alknet-tls.
#[error("TLS config construction: {0}")]
TlsConfig(#[from] alknet_tls::TlsError),
/// Transport connect failure — quinn connect, TcpStream::connect,
/// or iroh connect. The transport's own error type, stringified.
#[error("transport connect: {0}")]
Connect(String),
/// TLS handshake failure — the handshake started but failed
/// (rejected cert, ALPN mismatch, unknown raw-key remote
/// fail-closed). Distinct from TlsConfig (which is pre-handshake).
#[error("TLS handshake: {0}")]
Handshake(String),
/// No transport handle configured for the requested dial — e.g.,
/// `dial_quic` called but `with_quinn` was not set.
#[error("no transport handle configured for {transport}")]
NoTransport { transport: &'static str },
}
```
`TlsConfig` wraps `alknet_tls::TlsError` (ADR-088) — the config
construction errors. `Connect` and `Handshake` are transport-level
failures (pre- and post-handshake). `NoTransport` is a wiring error
(calling a dial without the matching `with_*`).
**`Handshake` resolves an ADR-088 §6 deferral.** ADR-088 §6 explicitly
scoped `TlsError` to *config-construction* errors and deferred the
handshake-error surfacing question to "the dial-seam ADR" (OQ-55, then
deferred). ADR-089 is that ADR. `ClientDialError::Handshake` is the
resolution: handshake-time errors (rejected cert, ALPN mismatch,
unknown-raw-key fail-closed) surface through the dial's error type as
`Handshake(String)`, not through `TlsError` (which stays
config-construction-only). This keeps ADR-088's scope boundary intact
while giving the dial a single error enum for all failure categories.
`Connect(String)` and `Handshake(String)` take `String` rather than
wrapping the concrete transport error types (`quinn::ConnectError`,
`io::Error`, `rustls::Error`) because the three transports' error types
are non-unifiable — the dial is transport-polymorphic, and there is no
single source type that covers quinn, tokio-rustls, and iroh. The
category is in the variant (`Connect` vs `Handshake`); the detail is in
the string. This differs from ADR-088's `TlsError` (which wraps concrete
types via `#[from]`) because `TlsError` has one source crate (rustls +
pemfile + rcgen), while `ClientDialError` spans three transport crates.
The variant granularity is decided; the exact string contents are an
implementation detail.
### Feature gates
```toml
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn"]
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
iroh = ["dep:iroh"]
```
A deployment that dials QUIC only enables `quinn`. A deployment that
dials TCP+TLS enables `tcp`. A deployment that dials iroh enables
`iroh`. A full native client (QUIC + TCP+TLS fallback + iroh) enables
all three. The `quinn` and `tcp` features pull the corresponding
features on `alknet-tls` (for `TlsClientConfig::for_quinn` /
`for_tcp_tls`). The `iroh` feature does not pull `alknet-tls` features
— iroh has its own TLS.
### Dependencies
```
alknet-client
├── alknet-core (Connection, CallCredentials/RemoteIdentity if
│ moved here, Ed25519SecretKey, types)
├── alknet-tls (TlsClientConfig — for quinn + tcp dials)
├── quinn (optional — dial_quic)
├── tokio-rustls (optional — dial_tcp_tls)
├── tokio (TcpStream, spawn)
├── iroh (optional — dial_iroh)
└── thiserror (ClientDialError)
```
`alknet-client` depends on `alknet-tls` (for `TlsClientConfig`) and
`alknet-core` (for `Connection` and types). It does **not** depend on
`alknet-call` or `alknet-channels-call` — the dial is below the
protocol. If `CallCredentials` / `RemoteIdentity` stay in
`alknet-call`, `alknet-client` depends on `alknet-call` for the type
only; the cleaner option (moving the credential type to `alknet-core`
or `alknet-client`) keeps the dial below the protocol. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §5 —
this is a two-way-door implementation detail.
## Crate dependencies (in the dep graph)
```
alknet-client
├── alknet-tls (TlsClientConfig)
│ └── alknet-core
└── alknet-core (Connection, types)
alknet-hub (uses AlknetClient for outbound worker dials)
├── alknet-client (the dial — the hub's dial_worker closure calls it)
├── alknet-channels-call (ChannelClient — the take-over)
├── alknet-call (CallAdapter, Dispatcher)
└── alknet-core (AlknetEndpoint)
alknet-worker (uses AlknetClient to dial a hub)
├── alknet-client (the dial)
├── alknet-channels-call (ChannelClient — the take-over)
└── alknet-core (AlknetEndpoint — if the worker accepts inbound)
```
`alknet-call` and `alknet-channels-call` do **not** depend on
`alknet-client`. Their take-over APIs (`spawn_dispatch`,
`from_connection`) consume a `Connection` from any source —
`AlknetClient` is one producer, but a test can hand them a
`Connection::from_stream` directly. The dependency direction is:
`alknet-client → alknet-tls → alknet-core`; the protocol crates are
parallel, not downstream of the dial.
## Assembly layer integration
A downstream worker or hub uses `alknet-client` like this:
```rust
// 1. Build the transport handles (assembly layer — same pattern as
// the server side's AlknetEndpoint builder).
let quinn_endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
// 2. Build the AlknetClient with the transport handles it needs.
let client = AlknetClient::new()
.with_quinn(quinn_endpoint);
// .with_tcp_tls(tls_connector) — if TCP+TLS fallback is needed
// .with_iroh(iroh_endpoint) — if iroh is needed
// 3. Derive credentials from the vault (ADR-014 — no env vars).
let creds = CallCredentials::new()
.with_tls_identity(TlsIdentity::RawKey(local_key))
.with_remote_identity(RemoteIdentity {
fingerprint: hub_fingerprint, // known peer → fingerprint pin
});
// 4. Dial the hub on alknet/channels, take over as channels.
let conn = client
.dial_quic(hub_addr, "alknet", b"alknet/channels", &creds)
.await?;
let channels = ChannelClient::from_connection(conn).await?;
// 5. Discover the hub's operations via from_call on channel 0.
let bundles = from_call(channels.call(), FromCallConfig::new()).await?;
channels.register_imported_all(bundles);
```
The hub's `supervise_worker` (hub README §"Dial") takes a `dial`
closure that produces a `Connection`. That closure can call
`client.dial_quic(...)` internally — the hub does not need to know
`AlknetClient` exists. The closure seam is preserved; `AlknetClient` is
the recommended dial producer for it.
## Design Decisions
All design decisions are documented as ADRs in
[decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — native client dial seam | New crate `alknet-client`; client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); resolves OQ-55; `alknet/register` named, wire protocol deferred |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-55** (resolved by ADR-089): `AlknetClient` native dial seam —
the transport-polymorphic dial is extracted. The deferral's blocking
condition (a second transport's real dial) is met within the native
endpoint type (QUIC + TCP+TLS + iroh). The web/browser client
(WebSocket, HTTP) was never in scope. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
- **OQ-66** (deferred(scope)): `alknet/register` wire protocol — the
native registration handshake (token / no-token, the frames,
`PeerEntry` creation, session credential return). Named as a
dialable ALPN by ADR-089; the wire protocol ties into the call
crate's ACL and OQ-58 (the token model is the shared blocker) and
needs a dedicated ADR. The HTTP registration endpoint (OQ-58)
remains the first implementation.
## References
- [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) —
the decision this spec implements
- [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) —
`AlknetEndpoint` (the server-side shape this spec mirrors)
- [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) —
endpoint types (native = QUIC + TCP+TLS + iroh); entry-point vs.
endpoint ALPN distinction
- [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md)
— `TlsClientConfig` (the prerequisite the dial consumes)
- [ADR-082](../../decisions/082-alknet-tls-extraction.md) —
`TlsServerConfig` / `TlsClientConfig` in `alknet-tls`
- [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
— `Connection::from_stream` / `from_bidi` (the `Connection`
constructors the dials use)
- [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md)
— client-side verifier selection (fingerprint pin vs CA vs
fail-closed)
- [ADR-084](../../decisions/084-aws-lc-rs-crypto-provider.md) —
aws-lc-rs crypto provider
- [ADR-080](../../decisions/080-channelclient.md) —
`ChannelClient::from_connection` (the take-over the dial feeds)
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
— `CallClient::spawn_dispatch` (the take-over the dial feeds)
- [`crates/core/endpoint.md`](../core/endpoint.md) — `AlknetEndpoint`
(the server-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig`
- [`crates/call/client-and-adapters.md`](../call/client-and-adapters.md)
— `CallClient` (the protocol take-over)
- [`crates/channels/channel-client.md`](../channels/channel-client.md)
— `ChannelClient` (the protocol take-over)
- [`crates/hub/README.md`](../hub/README.md) §"Dial (outbound workers)"
— the hub-as-client case (the `supervise_worker` closure that calls
the dial)
- OQ-55 (resolved) — `AlknetClient` / client establishment extraction
- OQ-58 — worker registration flow (the HTTP path; `alknet/register`
is the native analogue)
- OQ-66 (deferred) — `alknet/register` wire protocol
+1 -1
View File
@@ -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 **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. |
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved by ADR-089 | The native dial seam is extracted as `alknet-client` — the client-side analogue of `AlknetEndpoint`. Three dial methods (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key). The client take-over APIs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection` — ADR-080) are transport-agnostic and decided; the shared dial is now extracted. |
## Key Design Principles
+11 -6
View File
@@ -320,12 +320,16 @@ taking over the `Connection`, it runs `from_call` on channel 0 to
discover the worker's operations, registers the discovered bundles in
the connection's Layer 2 overlay, and attaches the peer to the
aggregated env. `connect_quic_worker` is the "I just want QUIC"
convenience — it builds a `TlsClientConfig` (ADR-087) with the
worker's fingerprint pinned, calls `ChannelClient::connect_quic`, then
`dial_worker_connection`. A future `connect_tcp_tls_worker` builds a
`TlsClientConfig` and dials TCP+TLS the same way. The one-way-door
surface is `dial_worker_connection`; the dial helpers are two-way-door
conveniences.
convenience — it dials QUIC (via `AlknetClient::dial_quic`, ADR-089,
which builds the `TlsClientConfig` with the worker's fingerprint
pinned), calls `ChannelClient::from_connection`, then
`dial_worker_connection`. A future `connect_tcp_tls_worker` dials
TCP+TLS via `AlknetClient::dial_tcp_tls` the same way. The
one-way-door surface is `dial_worker_connection`; the dial helpers are
two-way-door conveniences over `AlknetClient` (ADR-089). The hub's
`supervise_worker` (below) takes a `dial` closure that can call
`AlknetClient` internally — the hub does not need to know
`AlknetClient` exists; the closure seam is preserved.
#### Accept (inbound workers and browsers) — transport-agnostic
@@ -787,6 +791,7 @@ into `CallAdapter::with_aggregated_env`.
| Channel lifecycle operations | [ADR-073](../../decisions/073-channel-lifecycle-operations.md) | `channel/open`/`close`/`control`/`resources/subscribe` — what the hub translates |
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) |
| `AlknetClient` native dial seam | [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) | New crate `alknet-client`; the hub's outbound worker dials use `AlknetClient` (via the `supervise_worker` closure or the `connect_quic_worker` convenience); resolves OQ-55 |
## Open Questions
+38 -44
View File
@@ -461,12 +461,12 @@ TLS setup and cert sharing, not transport accept logic.
A hub dials out to workers it supervises and to other hubs
(hub-as-client); `alknet-worker` dials a hub. Both need a
`rustls::ClientConfig` with ADR-034's verifier selection and ADR-084's
crypto provider. `TlsClientConfig` centralizes this — it is not a
future extraction deferred behind the dial seam (OQ-55); it is a
present prerequisite for the first hub deployment.
crypto provider. `TlsClientConfig` centralizes this — it is a
present prerequisite for the first hub deployment, consumed by
`AlknetClient`'s QUIC and TCP+TLS dials (ADR-089).
There are exactly two clients in the alknet client surface as far as
`TlsClientConfig` and a future `AlknetClient` are concerned — **call**
`TlsClientConfig` and `AlknetClient` are concerned — **call**
(`CallClient`) and **channels** (`ChannelClient`, which is a proxy over
many ALPNs via channel 0). Both must support all three transport
accessors below; the TLS config is shared across them, the dial is
@@ -536,22 +536,21 @@ both server and client errors) is decided — see
[`TlsError`](#tlserror) section below.
`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the
transport-specific dial helper — `CallClient::connect_quic`, a future
`connect_tcp_tls`, etc.) passes it to the transport's connector. The
transport-specific dial helper — `AlknetClient::dial_quic` /
`dial_tcp_tls`, ADR-089) passes it to the transport's connector. The
config is transport-agnostic; the dial is not. This is the client-side
analogue of ADR-065's server-side separation: the take-over
(`spawn_dispatch` / `from_connection`, transport-agnostic) is built
now; the dial (transport-specific) is per-transport. The
transport-polymorphic dial extraction (`AlknetClient::dial()`) remains
deferred (OQ-55) — it is about picking the transport and calling the
right connector, not about the TLS config.
transport-polymorphic dial is now extracted as `alknet-client`
(ADR-089, resolves OQ-55) — `AlknetClient` builds the `TlsClientConfig`
per-dial and calls the transport's connector.
The client-side accessor API mirrors the server side: `for_quinn()`
/ `for_tcp_tls()` / `rustls_config()` — three transports, same
pattern. Iroh is the exception (see below). Both `CallClient` and
`ChannelClient` consume `TlsClientConfig` via these accessors; the
shared dial seam (OQ-55) is about collapsing the per-transport dial
boilerplate, not about the TLS config.
pattern. Iroh is the exception (see below). `AlknetClient` (ADR-089)
consumes `TlsClientConfig` via these accessors for the QUIC and TCP+TLS
dials; the iroh dial is the key-not-config exception.
### Iroh — shares the key, not the config (client side too)
@@ -722,44 +721,39 @@ See [open-questions.md](../../open-questions.md) for full details.
and the "what is NOT a variant" list. The `TlsError` sketch is in the
[TlsError](#tlserror) section below.
- **OQ-64** (resolved): `alknet-tls` provides `TlsClientConfig`
(ADR-087). Not blocked on the dial-seam extraction (OQ-55) — the TLS
(ADR-087). Not blocked on the dial-seam extraction — the TLS
config is a prerequisite for the dial, not a consequence of it.
Centralizes ADR-034 verifier selection + ADR-084 provider; the
hub-as-client requirement makes it a prerequisite for the first hub
deployment. The dial seam (OQ-55) remains deferred; the TLS config
does not.
deployment. The dial seam is now extracted as `alknet-client`
(ADR-089, OQ-55 resolved); `TlsClientConfig` is consumed by
`AlknetClient`'s QUIC and TCP+TLS dials.
- **OQ-55** (deferred(scope)): `AlknetClient::dial()` — the
transport-polymorphic dial seam. Remains deferred (blocked on a
second transport's real dial). `TlsClientConfig` (OQ-64, resolved)
is not blocked on this; the dial helpers (`CallClient::connect_quic`,
a future `connect_tcp_tls`, etc.) each build a `TlsClientConfig` and
call their transport's connector standalone. See
[OQ-55](../../questions/055-alknetclient-establishment-extraction.md).
- **OQ-55** (resolved by ADR-089): `AlknetClient::dial()` — the
transport-polymorphic dial seam. Extracted as a new crate
`alknet-client` with three dial methods (`dial_quic` /
`dial_tcp_tls` / `dial_iroh`). `TlsClientConfig` (OQ-64, resolved)
is the prerequisite the dial consumes. See
[`crates/client/README.md`](../client/README.md) and
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
### Next session — client shape
The client can now be defined. There are exactly two clients in the
alknet client surface as far as `TlsClientConfig` and a future
`AlknetClient` are concerned: **call** (`CallClient`) and **channels**
(`ChannelClient`, a proxy over many ALPNs via channel 0). Both consume
`TlsClientConfig` via the same three accessors (`for_quinn`,
`for_tcp_tls`, `rustls_config`); iroh is the exception (shares the key,
not the config). The connect details across `register`, `call`, and
`channels` are the same at the TLS layer — an endpoint is one of a few
well-specified types now (ADR-086), and the `TlsClientConfig` shape is
the same regardless of which ALPN the client dials.
The client spec was previously deferred because there was no scope for
it — the endpoint-types tangle (uncovered during the channels spec work)
had to be pulled apart first. That tangle is resolved (ADR-086); the
endpoint types are well-specified, and the client shape collapses to
"same as `CallClient`, different ALPN." The next session should stop
hedging the client and work out the details: the `AlknetClient` dial
surface (OQ-55, unblocked once two transport dials exist), the
`CallClient` / `ChannelClient` shared dial pattern, and the `register`
ALPN's entry-point dial. This is a prerequisite for the first hub
deployment (the hub dials workers; workers dial a hub).
The client is now specced. [`crates/client/README.md`](../client/README.md)
defines `AlknetClient` — the native client dial seam (ADR-089, resolves
OQ-55). There are exactly two clients in the alknet client surface as
far as `TlsClientConfig` and `AlknetClient` are concerned: **call**
(`CallClient`) and **channels** (`ChannelClient`, a proxy over many
ALPNs via channel 0). Both consume `TlsClientConfig` via the same three
accessors (`for_quinn`, `for_tcp_tls`, `rustls_config`); iroh is the
exception (shares the key, not the config). `AlknetClient` is the dial
that feeds them — it produces a `Connection` and the protocol
take-overs (`spawn_dispatch`, `from_connection`) consume it. The
existing `CallClient::connect` / `ChannelClient::connect_quic`
convenience constructors become thin wrappers over
`AlknetClient::dial_quic`. The `alknet/register` ALPN (native
registration entry point, parallel to HTTP registration in OQ-58) is
named by ADR-089; its wire protocol is deferred (OQ-66).
## References