diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 83fcf2b..c9aa858 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -71,9 +71,8 @@ data channels byte-forwarded with `channel_id` rewrite; the hub never runs protocol-specific handlers), [ADR-080](decisions/080-channelclient.md) (`ChannelClient`, 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), +bidirectionality preserved; `AlknetClient` dial-seam extracted as +`alknet-client` per ADR-089, resolving OQ-55), [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 + @@ -188,6 +187,7 @@ adapter location map is now consistent: all HTTP-backed adapters | [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior | | [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery | | [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) | +| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); produces `Connection` for `CallClient`/`ChannelClient` take-over; `alknet/register` named (wire protocol deferred, OQ-66) | | [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream | | [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates | | [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) | @@ -288,6 +288,7 @@ adapter location map is now consistent: all HTTP-backed adapters | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted | | [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted | | [088](decisions/088-tlserror-shape.md) | `TlsError` Shape — Single Enum, Owned by `alknet-tls` | Accepted | +| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55) | ## Open Questions diff --git a/docs/architecture/crates/call/client-and-adapters.md b/docs/architecture/crates/call/client-and-adapters.md index 1888325..dbf9383 100644 --- a/docs/architecture/crates/call/client-and-adapters.md +++ b/docs/architecture/crates/call/client-and-adapters.md @@ -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, diff --git a/docs/architecture/crates/channels/README.md b/docs/architecture/crates/channels/README.md index 53e8445..5a4731f 100644 --- a/docs/architecture/crates/channels/README.md +++ b/docs/architecture/crates/channels/README.md @@ -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) | diff --git a/docs/architecture/crates/channels/channel-client.md b/docs/architecture/crates/channels/channel-client.md index 8ade2d2..d3a021f 100644 --- a/docs/architecture/crates/channels/channel-client.md +++ b/docs/architecture/crates/channels/channel-client.md @@ -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 diff --git a/docs/architecture/crates/channels/overview.md b/docs/architecture/crates/channels/overview.md index 606d0e8..d67a9a8 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; 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. diff --git a/docs/architecture/crates/client/README.md b/docs/architecture/crates/client/README.md new file mode 100644 index 0000000..3e16b83 --- /dev/null +++ b/docs/architecture/crates/client/README.md @@ -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, + #[cfg(feature = "tcp")] + tcp_connector: Option, + #[cfg(feature = "iroh")] + iroh: Option, +} + +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; + + /// 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; + + /// 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; +} +``` + +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 \ No newline at end of file diff --git a/docs/architecture/crates/core/README.md b/docs/architecture/crates/core/README.md index e53ed49..fc756b6 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 **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 diff --git a/docs/architecture/crates/hub/README.md b/docs/architecture/crates/hub/README.md index 1ac8d8c..e65f000 100644 --- a/docs/architecture/crates/hub/README.md +++ b/docs/architecture/crates/hub/README.md @@ -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 diff --git a/docs/architecture/crates/tls/README.md b/docs/architecture/crates/tls/README.md index 3b3d0a8..10a028d 100644 --- a/docs/architecture/crates/tls/README.md +++ b/docs/architecture/crates/tls/README.md @@ -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 diff --git a/docs/architecture/decisions/089-alknetclient-native-dial-seam.md b/docs/architecture/decisions/089-alknetclient-native-dial-seam.md new file mode 100644 index 0000000..ca072a6 --- /dev/null +++ b/docs/architecture/decisions/089-alknetclient-native-dial-seam.md @@ -0,0 +1,413 @@ +# ADR-089: AlknetClient — the Native Client Dial Seam + +## Status + +Accepted (resolves OQ-55) + +## Context + +### The deferral and why it was valid + +OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client +dial seam — blocked on "a second transport's real dial existing." The +reasoning (recorded in the OQ-55 file and in +[channel-client.md](../crates/channels/channel-client.md) §"Relationship +to `AlknetClient`") was sound at the time: extracting a QUIC-shaped +connector and naming it `AlknetClient` would bake QUIC in as *the* +establishment shape — the same welding ADR-065 unwound on the server +side. Only one transport dial existed (`CallClient::connect` / +`ChannelClient::connect_quic`, both QUIC). The transport-polymorphic +seam was not extractable from two *different* transport implementations; +it was guessable from one. + +### Why the deferral has collapsed + +Three decisions landed since the deferral, each removing a blocker: + +1. **ADR-086 (endpoint types)** named the native endpoint type and gave + it **two rustls-consuming transports**: QUIC (primary) and TCP+TLS + (fallback when UDP is blocked). Both consume `TlsClientConfig`; both + produce a `Connection` via `Connection::from_quinn_with_alpn` / + `Connection::from_bidi`. The native endpoint type also includes iroh + (key-based, not rustls-consuming). That is **three dial shapes within + one endpoint type** — two sharing `TlsClientConfig`, one using the + raw key directly. The OQ-55 blocking condition ("a second + transport's real dial existing") is met *within one endpoint type*, + not across two unrelated transports. + +2. **ADR-087 (`TlsClientConfig`)** broke the circular hedge that linked + the TLS config to the dial. The client-side TLS config is extracted + and buildable today; it is a **prerequisite** for the dial, not a + consequence of it. Each transport-specific dial helper builds a + `TlsClientConfig` and passes it to its transport's connector. The + dial no longer waits on the TLS config; the TLS config is shared. + +3. **ADR-083 (endpoint as accept-loop runner)** made the server side a + clean accept-loop runner that takes pre-built transports via + `with_quinn` / `with_iroh` / `with_tcp_tls`. The client-side analogue + — a dialer that takes pre-built transport handles and produces a + `Connection` — is now guessable by symmetry, not a shot in the dark. + The server side separates "build the transport" (assembly layer) + from "run the accept loop" (endpoint); the client side separates + "build the transport handle" (assembly layer) from "dial + produce + `Connection`" (`AlknetClient`). + +The three together remove every blocker the deferral named. The dial +seam is extractable from two different rustls-consuming transport +implementations (QUIC + TCP+TLS) plus the key-based iroh path — three +real shapes, not one. The TLS config is shared. The server-side shape +gives the client-side shape by symmetry. + +### The tangle this ADR also names + +Three concept levels were conflated throughout the initial development, +contributing to the confusion that made `AlknetClient` hard to spec: + +1. **Deployment role** — Hub / Worker / Hub-Worker. *Who accepts, who + dials, in the hub-and-spoke topology.* A hub accepts inbound and may + dial outbound (hub-as-client). A worker dials outbound and may + accept inbound (a hub-worker). A pure worker only dials. +2. **Establishment side** — `AlknetEndpoint` (server) / `AlknetClient` + (client). *Server-side accept vs. client-side dial.* The endpoint + accepts connections and resolves identity from the incoming + connection; the client dials and presents identity (client cert) + while verifying the remote (ADR-034). +3. **ALPN-level category** — endpoint ALPN / entry-point ALPN + (ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.* + +These are orthogonal. A hub *uses* an `AlknetEndpoint` (server side) AND +*uses* an `AlknetClient` (client side, when dialing workers). A worker +*uses* an `AlknetClient` (client side) AND *may* use an `AlknetEndpoint` +(server side, 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. + +## Decision + +### 1. `alknet-client` is a new crate + +`AlknetClient` lives in a new crate `alknet-client`, not in +`alknet-core` or `alknet-tls`. The dependency profile rules out the +alternatives: + +- **`alknet-core` is ruled out by a cycle.** `AlknetClient` needs + `TlsClientConfig` from `alknet-tls`, and `alknet-tls` depends on + `alknet-core`. Putting `AlknetClient` in core creates + `alknet-core → alknet-tls → alknet-core` — a circular dependency. +- **`alknet-tls` is the wrong scope.** That crate is "TLS config + cert + sharing" (`TlsServerConfig` / `TlsClientConfig`), not "dial + + transport establishment." The dial calls `quinn::Endpoint::connect`, + `TcpStream::connect` + `TlsConnector::connect`, and + `iroh::Endpoint::connect` — transport-connection establishment, not + TLS config. Putting the dial in `alknet-tls` would weld transport + establishment to cert config, the same conflation ADR-082 untangled + on the server side. +- **Folding into `alknet-hub` / `alknet-worker`** would make both + depend on each other or duplicate the dial. Both roles need the dial; + the dial is not a role concern. + +`alknet-client` depends on `alknet-core` (for `Connection`, +`CallCredentials`, `RemoteIdentity`, types) + `alknet-tls` (for +`TlsClientConfig`) + transport crates (quinn, tokio-rustls, iroh — +feature-gated). The DAG is clean: +`alknet-client → alknet-tls → alknet-core`. `alknet-hub` and +`alknet-worker` (and any assembly layer) depend on `alknet-client` for +the dial; `alknet-call` and `alknet-channels-call` do not — their +take-over APIs (`spawn_dispatch`, `from_connection`) consume the +`Connection` the dial produces, without knowing `AlknetClient` produced +it. + +### 2. `AlknetClient` is the client-side analogue of `AlknetEndpoint` + +`AlknetEndpoint` (ADR-083) is a multi-transport **accept-loop runner**: +it takes pre-built transport endpoints and runs their accept loops, +dispatching by ALPN. `AlknetClient` is a multi-transport **dialer**: it +takes pre-built transport handles and dials a remote endpoint on a +chosen ALPN, producing a `Connection` for the protocol take-overs to +consume. + +The symmetry: + +| Concern | `AlknetEndpoint` (server) | `AlknetClient` (client) | +|---------|---------------------------|-------------------------| +| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | +| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) | +| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (ADR-034 — fingerprint pin or CA) | +| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do | +| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) | + +`AlknetClient` produces a `Connection`; the protocol take-overs +(`CallClient::spawn_dispatch`, `ChannelClient::from_connection`) take +over from there. This is the exact analogue of `AlknetEndpoint` +producing a `Connection` for `ProtocolHandler::handle`. + +### 3. Three dial methods, one per transport family + +```rust +pub struct AlknetClient { + // Pre-built transport handles, all optional — the client dials + // with whichever transport the remote endpoint type implies. + #[cfg(feature = "quinn")] + quinn: Option, + #[cfg(feature = "tcp")] + tcp_connector: Option, + #[cfg(feature = "iroh")] + iroh: Option, +} + +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`. Feature-gated on `quinn`. + #[cfg(feature = "quinn")] + pub async fn dial_quic( + &self, + addr: SocketAddr, + server_name: &str, + alpn: &[u8], + credentials: &CallCredentials, + ) -> Result; + + /// TCP+TLS dial. Builds a `TlsClientConfig` from `credentials`, + /// connects `TcpStream`, wraps with `TlsConnector`, returns a + /// `Connection` via `Connection::from_bidi`. Feature-gated on `tcp`. + #[cfg(feature = "tcp")] + pub async fn dial_tcp_tls( + &self, + host: &str, + addr: SocketAddr, + alpn: &[u8], + credentials: &CallCredentials, + ) -> Result; + + /// 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). The verifier is iroh's `NodeId` match (fingerprint + /// pin by another name). 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; +} +``` + +The three dials share `CallCredentials` (the local identity + remote +identity + auth token bundle, from `Capabilities`). The two rustls dials +(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the +iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the +server side's "iroh shares the key, not the config" (ADR-082, ADR-087 +§3) — the consistency is in the rule (ADR-034 verifier selection), not +in the type. + +### 4. The dial is transport-polymorphic across the native endpoint type + +The native endpoint type (ADR-086) has QUIC + TCP+TLS (both +rustls-consuming) + iroh (key-based). `AlknetClient` dials all three. +The two rustls dials share `TlsClientConfig::new`; the iroh dial is the +exception. The dial is transport-polymorphic within the native endpoint +type — a native client can reach a native endpoint over QUIC, TCP+TLS +(when UDP is blocked), or iroh (relay-assisted p2p). The transport +choice is the caller's, driven by network conditions and the remote +endpoint's reachability. + +### 5. `CallClient::connect` / `ChannelClient::connect_quic` delegate + +The existing QUIC convenience constructors on `CallClient` and +`ChannelClient` (`connect` / `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`. +The one-way-door surface is the `AlknetClient` dial + take-over pattern; +the per-protocol convenience constructors are two-way-door sugar over +it. This does not break the existing APIs — 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. + +### 6. `alknet/register` is a dialable ALPN (entry point, wire protocol deferred) + +`AlknetClient::dial_quic` / `dial_tcp_tls` can dial the `alknet/register` +ALPN — the native registration entry point, parallel to HTTP +registration (OQ-58) but without the HTTP layer. 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 is the same as any other ALPN; the +difference is the protocol that runs on the resulting `Connection`. + +Two registration cases, both hub concerns and both optional: + +- **Token registration** — a freshly-provisioned worker (docker, + vast.ai, runpod) generates its local identity, dials the hub on + `alknet/register`, presents the one-time registration token, and + enrolls its key. The hub creates a `PeerEntry` and returns a session + credential. +- **No-token (open) registration** — a hub that hosts public services + over channels, or a relay/gateway, accepts registration without a + token. The enrollment creates a `PeerEntry` with no token + requirement. + +The `alknet/register` **wire protocol** (the handshake on the +`Connection` after the dial — 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 ADR names the ALPN +and its entry-point role; it does not specify the wire protocol. The +HTTP registration endpoint (OQ-58) remains the first implementation; +`alknet/register` is the native analogue that removes the HTTP +dependency for workers that have no HTTP client. + +### 7. OQ-55 is resolved for the native dial + +OQ-55's blocking condition ("a second transport's real dial existing") +is met: the native endpoint type has two rustls-consuming transports +(QUIC + TCP+TLS) + iroh (key-based) — three dial shapes, two sharing +`TlsClientConfig`. The transport-polymorphic dial seam is extractable +from two different transport implementations. `AlknetClient` is that +seam, for the native case. + +The **web/browser client** (WebSocket, HTTP — the browser bidirectional +path per ADR-044/048) was never what OQ-55 was about. The browser path +is a different client surface (the JS SDK / wasm), not a Rust dial. It +does not use `AlknetClient`; it negotiates TLS via the browser's +network stack and speaks the wire protocol over WebSocket. OQ-55 +deferred the Rust transport-polymorphic dial; the browser path is out +of scope and always was. The non-Rust native clients (Node/Deno/Bun, +Python, wasm) that can negotiate TLS against an X.509 endpoint and +implement the wire protocols directly are also out of scope for +`AlknetClient` — `AlknetClient` is the Rust native client, one of +several possible native clients sharing the same wire protocols. + +## What this does NOT change + +- **`AlknetEndpoint` (ADR-083)** — the server side is unchanged. The + client is a new type, not a modification to the endpoint. +- **`TlsClientConfig` (ADR-087)** — the client-side TLS config is + unchanged. `AlknetClient` calls `TlsClientConfig::new` per-dial; the + config is a prerequisite, not a consequence of the dial (the + relationship ADR-087 established). +- **`CallClient::spawn_dispatch` / `ChannelClient::from_connection`** + — the take-over APIs are unchanged. They consume the `Connection` + the dial produces; they do not know `AlknetClient` produced it. +- **`CallCredentials` / `RemoteIdentity`** — unchanged. The credential + bundle `AlknetClient` takes is the existing type from `alknet-call` + (or moved to `alknet-client` / `alknet-core` as a shared type — an + implementation detail; the shape is decided). +- **The channels substrate (ADR-071)** — unchanged. The dial produces a + `Connection`; the channels protocol runs on it. +- **ADR-086 (endpoint types / entry points)** — the endpoint-type model + is unchanged. `AlknetClient` is the client-side consumer of the + native endpoint type. The entry-point vs. endpoint ALPN distinction + (§2) governs which ALPNs the client can dial and whether identity is + required — `AlknetClient` dials both; the protocol on the resulting + `Connection` differs. +- **The hub's `supervise_worker` (hub README §"Dial")** — the hub's + supervision loop takes a `dial` closure that produces a + `Connection`. That closure can call `AlknetClient::dial_quic` / + `dial_tcp_tls` internally. The hub does not need to know + `AlknetClient` exists — the closure seam is preserved. The hub spec + is updated to note `AlknetClient` as the recommended dial producer + for the closure. + +## Consequences + +**Positive:** + +- **OQ-55 is resolved.** The transport-polymorphic dial seam is + extracted, for the native case. The duplicated dial boilerplate + (each convenience constructor rebuilding `TlsClientConfig::new` + + its transport's connector) is centralized in `AlknetClient`. The + friction the deferral accepted is removed. +- **The client-side shape is symmetric with the server side.** A + reader who understands `AlknetEndpoint` (accept + dispatch) can + understand `AlknetClient` (dial + produce `Connection`) by + symmetry. The concept layers (role / side / ALPN-category) are + named, reducing the tangle that made the client hard to spec. +- **The hub-as-client case is first-class.** A hub that dials workers + (or another hub) uses `AlknetClient` — the same type a worker uses + to dial a hub. The role asymmetry (hub vs. worker) does not produce + a type asymmetry; both use the same client. +- **Transport selection is the caller's.** A native client that needs + QUIC-with-TCP+TLS-fallback dials QUIC first, falls back to TCP+TLS + on connection failure. `AlknetClient` provides both dials; the + fallback policy is a caller concern (or a future `dial_with_fallback` + helper — two-way-door). +- **`alknet/register` is named.** The native registration entry point + has a home in the ALPN registry, parallel to HTTP registration. The + wire protocol is deferred, but the ALPN and its entry-point role are + decided — a worker that has no HTTP client can register natively. + +**Negative:** + +- **A new crate.** `alknet-client` is one more crate in the workspace. + The cost is low (the dial is narrow), and the dependency profile + rules out the alternatives, but it is a new entry in the crate + graph. +- **The iroh dial is the exception.** It does not use + `TlsClientConfig` — iroh has its own TLS. The dial helper applies + the same ADR-034 rule via iroh's API (NodeId match). The + consistency is in the rule, not in the type. This is the same + exception as the server side (ADR-082, ADR-087 §3) — unavoidable, + and isolated to one dial method. +- **The `alknet/register` wire protocol is still deferred.** This ADR + names the ALPN and its role; the handshake protocol (token/no-token, + the frames, the `PeerEntry` creation, the session credential return) + is a separate ADR tied to OQ-58. A worker cannot register natively + until that ADR lands; the HTTP path (OQ-58) remains the first + implementation. +- **The ADR-086 entry-point/endpoint terminology is under-specified + as a general abstraction.** This ADR uses the current terms + (entry-point = no identity at TLS; endpoint = identity required) but + does not re-litigate them. The broader abstraction — that all + top-level ALPNs are "entry points to the endpoint," each handling + auth in its own way — is a separate conceptual refinement, not + this ADR's scope. + +## Door type + +**One-way (crate existence + dial seam).** `alknet-client` as the +shared client dial crate is structural — every outbound-dialing role +(hub, worker, hub-worker) depends on it. Reversing would mean +re-distributing the dial across crates, reintroducing the duplicated +boilerplate. The three-dial API (`dial_quic` / `dial_tcp_tls` / +`dial_iroh`) is one-way — changing the signatures after consumers exist +is a rewrite. The internal implementation (how `CallCredentials` feeds +`TlsClientConfig::new`, how the iroh dial maps the `Ed25519SecretKey`) +is two-way. The `alknet/register` ALPN name is one-way (wire +compatibility); its wire protocol is two-way until the dedicated ADR +lands. + +## References + +- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment + extraction +- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint` + as multi-transport accept-loop runner; the server-side shape this + ADR mirrors on the client side +- [ADR-086](086-endpoint-types-and-entry-points.md) — endpoint types + (native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN + distinction (§2) +- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) — + `TlsClientConfig` not blocked on the dial seam; breaks the circular + hedge; the TLS config is a prerequisite for the dial +- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` / + `TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the + config" +- [ADR-065](065-connection-from-stream-generic-single-stream.md) — + `Connection::from_stream` / `from_bidi`; the server-side + generalization whose client-side analogue this ADR completes +- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) — + client-side verifier selection (fingerprint pin vs CA vs fail-closed) +- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto + provider on all paths +- [ADR-080](080-channelclient.md) — `ChannelClient::from_connection` + (the take-over `AlknetClient` feeds) +- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — + `CallClient::spawn_dispatch` (the take-over `AlknetClient` feeds) +- OQ-58 — worker registration flow (the HTTP path; `alknet/register` + is the native analogue) +- `docs/architecture/crates/channels/channel-client.md` §"Relationship + to `AlknetClient`" — the deferral this ADR resolves \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index bea3551..dd84761 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -91,7 +91,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis | [OQ-12](questions/012-tls-identity-provisioning-in-alknetendpoint.md) | TLS Identity Provisioning in AlknetEndpoint | resolved | one | high | | [OQ-13](questions/013-operation-path-format-and-routing-scope.md) | Operation Path Format and Routing Scope | resolved | two | med | | [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low | -| [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | deferred(scope) | two | med | +| [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | resolved | one | med | | [OQ-59](questions/059-fingerprint-module-location.md) | Should `fingerprint.rs` Stay in Core or Move to `alknet-tls`? | resolved | two | med | | [OQ-60](questions/060-transport-construction-location.md) | Where Does Transport Construction Live? | resolved | one | high | | [OQ-61](questions/061-multi-owner-shutdown-coordination.md) | Multi-Owner Shutdown Coordination | dissolved | two | med | @@ -203,6 +203,12 @@ Door type is separate from whether a decision is made. A two-way door is a decis | [OQ-63](questions/063-tlserror-shape.md) | `TlsError` Shape | resolved | one | high | | [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | resolved | one | high | +### alknet-client + +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-66](questions/066-alknet-register-wire-protocol.md) | `alknet/register` Wire Protocol | deferred(scope) | one | med | + ## Deferred / Blocked The safe-exit visibility surface. These questions are parked because the @@ -269,22 +275,34 @@ filtering the tables above. ### OQ-55: AlknetClient / Client Establishment Extraction -- **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 +- **Resolved** (ADR-089): `AlknetClient` is extracted as a new crate + `alknet-client` — the client-side analogue of `AlknetEndpoint` + (ADR-083). Three dial methods (`dial_quic` / `dial_tcp_tls` / + `dial_iroh`) produce a `Connection` for the protocol take-overs + (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`) to + consume. The deferral's blocking condition (a second transport's + real dial) is met within the native endpoint type (ADR-086): QUIC + + TCP+TLS (both via `TlsClientConfig`, ADR-087) + iroh (key-based) — + three dial shapes, two sharing `TlsClientConfig`. The web/browser + client (WebSocket, HTTP) was never in scope. See + [ADR-089](decisions/089-alknetclient-native-dial-seam.md) and + [OQ-55](questions/055-alknetclient-establishment-extraction.md). - **Full file**: [OQ-55](questions/055-alknetclient-establishment-extraction.md) +### OQ-66: `alknet/register` Wire Protocol + +- **Blocked on**: OQ-58's token model (one-time vs. refresh, + single-use vs. multi-use, rotation). The `alknet/register` wire + protocol and the HTTP registration endpoint (OQ-58) share the + enrollment semantics and the token model — they should converge on + one model before either's wire format is locked. +- **Priority**: medium +- **Impacts**: Blocks native worker registration over QUIC/TCP+TLS + without HTTP (the native-only / no-HTTP-minimal-hub case). Does NOT + block the first hub deployment (web + native uses HTTP registration + for worker provisioning per OQ-58). +- **Full file**: [OQ-66](questions/066-alknet-register-wire-protocol.md) + ### OQ-56: Full Channel-Level Flow-Control Windowing - **Blocked on**: a real deployment observes head-of-line blocking on a diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 75cecbf..9fc98d4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -99,13 +99,14 @@ alknet-vault (standalone — foundational to ACL: key derivation, identity) │ │ StaticConfig, DynamicConfig │ ├── alknet-tls TlsServerConfig + TlsClientConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082/087) │ ├── alknet-call CallAdapter on alknet/call, CallClient, OperationRegistry, adapters -│ └── alknet-channels -│ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081 -│ └── alknet-channels-call channel 0 pre-negotiation + lifecycle ops — ADR-081 +│ ├── alknet-channels +│ │ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081 +│ │ └── alknet-channels-call channel 0 pre-negotiation + lifecycle ops — ADR-081 +│ └── alknet-client AlknetClient — native client dial seam (QUIC + TCP+TLS + iroh); produces Connection for CallClient/ChannelClient take-over (ADR-089) │ ├── Deployment shapes -│ ├── alknet-hub channels hub — accepts workers, relays, aggregates (ADR-079) -│ └── alknet-worker channels worker — dials out to a hub [not yet specced] +│ ├── alknet-hub channels hub — accepts workers, relays, aggregates (ADR-079); dials workers via AlknetClient +│ └── alknet-worker channels worker — dials out to a hub via AlknetClient [not yet specced] │ ├── Foundational handlers (inside channels as data-channel ALPNs, or on the endpoint) │ ├── alknet-tty alknet/tty — specced (ADR-052–057), implemented @@ -371,6 +372,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Core mono-repo (substrate + deployment shapes + foundational handlers + vault) vs. consumer repos (docker, agent) | | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type (resolves OQ-62) | | [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | `alknet-tls` provides client-side TLS config; not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case | +| [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, OQ-66) | ## Open Questions diff --git a/docs/architecture/questions/055-alknetclient-establishment-extraction.md b/docs/architecture/questions/055-alknetclient-establishment-extraction.md index 0edc838..ffe0b05 100644 --- a/docs/architecture/questions/055-alknetclient-establishment-extraction.md +++ b/docs/architecture/questions/055-alknetclient-establishment-extraction.md @@ -5,83 +5,51 @@ §"Issues Surfaced" #1 (the `BidiStreamSource` finding that motivates separating the client-extraction question from the `Connection` extension question). -- **Status**: deferred(scope) -- **Door type**: two-way +- **Status**: resolved (ADR-089) +- **Door type**: one-way (crate existence + dial seam — ADR-089) - **Priority**: medium -- **Impacts**: Does NOT block individual transport dials — each - transport-specific dial helper builds its `TlsClientConfig` (ADR-087) - and its own connector standalone. Blocks only the *shared* - `AlknetClient::dial()` extraction (one entry point that picks the - transport and calls the right connector). Low impact until a second - transport's dial exists and the duplicated dial boilerplate becomes - worth extracting. -- **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`'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. -- **What is NOT deferred (amended by ADR-087)**: the client-side TLS - config (`TlsClientConfig`) is **not** part of this deferral. OQ-64 - is resolved — `alknet-tls` provides `TlsClientConfig` now, unblocked - by this OQ. The TLS config is a **prerequisite** for the dial, not a - consequence of it; it is transport-agnostic (ADR-034 verifier - selection + ADR-084 provider, both already decided). Each - transport-specific dial helper builds its `TlsClientConfig` and - passes it to its transport's connector. This OQ defers only the - *transport-polymorphic dial extraction* — the shared `AlknetClient::dial()` - that picks the transport and calls the right connector. When a second - transport's dial exists, the dial seam is extractable; the TLS config - is already shared by then. -- **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 - `rustls::ClientConfig` construction — now centralized in - `TlsClientConfig::new` (ADR-087, OQ-64 resolved). What remains - transport-specific is the *dial* itself — `quinn::Endpoint::connect`, - `TcpStream::connect` + `TlsConnector::connect`, iroh's - `Endpoint::connect`. 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 `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 TLS config is narrow and now - extracted (ADR-087). Until a second transport's dial exists, the seam - between "dial" (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 with a shared `TlsClientConfig` (ADR-087), and each - transport-specific dial helper. `CallClient`'s transport-agnostic +- **Resolution**: `AlknetClient` is extracted as a new crate + `alknet-client` — the client-side analogue of `AlknetEndpoint` + (ADR-083). Three dial methods: `dial_quic` / `dial_tcp_tls` (both + build a `TlsClientConfig` via ADR-087 and call their transport's + connector) + `dial_iroh` (the key-not-config exception, shares the + `Ed25519SecretKey`). The dial produces a `Connection` for the + protocol take-overs (`CallClient::spawn_dispatch`, + `ChannelClient::from_connection`) to consume — it does not run + protocols. +- **Why the deferral collapsed**: the blocking condition ("a second + transport's real dial existing") is met *within the native endpoint + type* (ADR-086): QUIC + TCP+TLS (both rustls-consuming via + `TlsClientConfig`) + iroh (key-based) — three dial shapes, two + sharing `TlsClientConfig`. ADR-087 broke the circular hedge + (`TlsClientConfig` is a prerequisite, not a consequence of the dial). + ADR-083 made the server-side shape a clean accept-loop runner, giving + the client-side shape by symmetry. The dial seam is extractable from + two different transport implementations, not guessable from one. +- **Scope of the resolution**: the **native** transport-polymorphic + dial. The web/browser client (WebSocket, HTTP — the browser + bidirectional path per ADR-044/048) was never what this OQ was + about — it is a different client surface (the JS SDK / wasm), not a + Rust dial, and does not use `AlknetClient`. Non-Rust native clients + (Node/Deno/Bun, Python, wasm) that negotiate TLS against an X.509 + endpoint and implement the wire protocols directly are also out of + scope — `AlknetClient` is the Rust native client, one of several + possible native clients sharing the same wire protocols. +- **What does NOT block on this (unchanged)**: each crate building its + own client standalone with a shared `TlsClientConfig` (ADR-087), 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 `TlsClientConfig` (ADR-087) and 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 the duplicated dial - boilerplate (each dial helper calls its transport's connector), not - duplicated TLS config (that is shared via ADR-087). 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 dial seam*, not a prerequisite for any single client to - exist. -- **Cross-references**: ADR-034 (verifier selection — centralized in - `TlsClientConfig::new` per ADR-087), ADR-087 (`TlsClientConfig` is - not blocked on this OQ — the TLS config is extracted; only the dial - remains deferred), ADR-065 (server-side transport generalization — - the client-side analogue this OQ's deferral avoids preempting), - ADR-070 (the `BidiStreamSource` extension point, which is the - *Connection* opening and is orthogonal to the *client* establishment - question), OQ-CH-14 in + take-over (`from_connection`, ADR-080) are decided; the existing + `connect` / `connect_quic` convenience constructors become thin + wrappers over `AlknetClient::dial_quic` (ADR-089 §5). +- **Cross-references**: ADR-089 (the resolution — `AlknetClient` native + dial seam), ADR-083 (the server-side shape mirrored), ADR-086 + (endpoint types — native has QUIC + TCP+TLS + iroh), ADR-087 + (`TlsClientConfig` — the prerequisite the dial consumes), ADR-034 + (verifier selection — centralized in `TlsClientConfig::new`), ADR-065 + (server-side transport generalization — the client-side analogue + this OQ's deferral avoided preempting, now completed), ADR-070 (the + `BidiStreamSource` extension point — orthogonal to the client + establishment question), OQ-CH-14 in `docs/research/alknet-channels/phase-0-findings.md` (the research-scope - question this core-scope OQ carries forward). \ No newline at end of file + question this core-scope OQ carried forward). \ No newline at end of file diff --git a/docs/architecture/questions/066-alknet-register-wire-protocol.md b/docs/architecture/questions/066-alknet-register-wire-protocol.md new file mode 100644 index 0000000..2c76942 --- /dev/null +++ b/docs/architecture/questions/066-alknet-register-wire-protocol.md @@ -0,0 +1,91 @@ +# OQ-66: `alknet/register` Wire Protocol + +- **Origin**: `docs/architecture/decisions/089-alknetclient-native-dial-seam.md` + §6; `docs/architecture/crates/client/README.md` §"`alknet/register`". +- **Status**: deferred(scope) +- **Door type**: one-way (the wire protocol — once native workers and + hubs exchange registration frames, the format is compatibility-locked) +- **Priority**: medium +- **Impacts**: Blocks native worker registration over QUIC/TCP+TLS + without HTTP. A worker that has no HTTP client (a minimal native + worker, an iroh-only deployment) cannot enroll its key with a hub + until this is specced and implemented. The HTTP registration + endpoint (OQ-58) remains the first implementation, so this does NOT + block the first hub deployment (web + native uses HTTP registration + for worker provisioning). It blocks the *native-only* registration + path and the no-HTTP minimal-hub case. +- **Blocked on**: OQ-58's token model (one-time vs. refresh, + single-use vs. multi-use, rotation). The `alknet/register` wire + protocol and the HTTP registration endpoint (OQ-58) share the + enrollment semantics and the token model — they should converge on + one model before either's wire format is locked. OQ-58 is open + (resolvable now); this OQ is blocked on its resolution. The + frame-format fork (reuse `EventEnvelope` vs. standalone) and the + enrollment-trait shape (point 5) are independently workable but + not independently decidable — they depend on the token model. +- **What is decided (ADR-089 §6)**: `alknet/register` is a dialable + ALPN — an **entry point** (ADR-086 §2) accepted without an + established peer identity. Two registration cases exist, both hub + concerns and both optional: + - **Token registration** — a freshly-provisioned worker (docker, + vast.ai, runpod) generates its local identity, dials the hub on + `alknet/register`, presents a one-time registration token, and + enrolls its key. The hub creates a `PeerEntry` (mixed-fingerprint + shape, ADR-034 §3) and returns a session credential. + - **No-token (open) registration** — a hub that hosts public + services over channels, or a relay/gateway, accepts registration + without a token. The enrollment creates a `PeerEntry` with no + token requirement. + The dial is the same as any other ALPN (`AlknetClient::dial_quic` / + `dial_tcp_tls` on `b"alknet/register"`); the difference is the + handshake protocol on the resulting `Connection`. +- **What is open**: the **wire protocol** — the handshake on the + `Connection` after the dial. Specifically: + 1. **Frame format** — does `alknet/register` reuse the call + protocol's `EventEnvelope` framing (length-prefixed JSON), or + does it have its own minimal framing? Registration is a one-shot + request/response (send public key + token, receive session + credential), not a long-lived call session. Reusing `EventEnvelope` + ties the register ALPN to `alknet-call` (a dependency); a minimal + own-format keeps it standalone but adds a second wire format. + 2. **Token model** — one-time vs. refresh, single-use vs. multi-use, + rotation. This is the same open question as OQ-58 (the HTTP + registration endpoint's token model). The two paths should share + the token model — a token issued by the hub works over both HTTP + and `alknet/register`. + 3. **No-token policy** — who decides whether a hub accepts + no-token registration? Is it a `DynamicConfig` flag, an + `IdentityProvider` policy, or a hub-crate config? What `PeerEntry` + shape does an open-registration enrollment produce (no + `auth_token_hash`, fingerprint-only)? + 4. **Session credential return** — what does the hub return on + successful registration? A `PeerEntry`? A session token? Both? + How does the returned credential feed into the subsequent + `alknet/channels` connection's `CallCredentials`? + 5. **Relationship to OQ-58** — the HTTP registration endpoint + (OQ-58) and `alknet/register` share the enrollment semantics + (create `PeerEntry`, return credential) but differ in transport + (HTTP vs. raw ALPN). Should they share an enrollment trait in + `alknet-hub`, with the HTTP endpoint and the `alknet/register` + handler as two transport-specific front-ends? Or are they + separate handlers that happen to call the same `IdentityStore` + write path? +- **Resolution**: Not yet decidable. The token model (point 2) is the + shared dependency with OQ-58 — both paths should converge on one + model. The frame format (point 1) is a genuine fork: reusing + `EventEnvelope` adds an `alknet-call` dependency to the register + path (which may be fine — the hub already depends on + `alknet-call`); a standalone format keeps register minimal but + diverges. The relationship to OQ-58 (point 5) is the shape question + that determines whether the register handler lives in `alknet-hub` + (alongside the HTTP endpoint) or in a separate crate. These need a + dedicated ADR that works through the token model, the frame format, + and the enrollment-trait shape together — they are not independently + decidable. +- **Cross-references**: ADR-089 (§6 — names the ALPN and its + entry-point role; defers the wire protocol to this OQ), OQ-58 + (worker registration flow — the HTTP path; shares the token model), + ADR-086 (§2 — entry-point vs. endpoint ALPN distinction), + ADR-034 (§3 — the mixed-fingerprint `PeerEntry` shape token + registration creates), ADR-072 (channel 0 identity resolution — + the path the session credential feeds into). \ No newline at end of file