Extract the deferred AlknetClient as a new crate alknet-client — the client-side analogue of AlknetEndpoint. Three dial methods (QUIC + TCP+TLS via TlsClientConfig, iroh via key) produce a Connection for CallClient::spawn_dispatch / ChannelClient::from_connection to consume. The deferral collapsed because ADR-086 gave the native endpoint type three dial shapes within one endpoint type, ADR-087 broke the circular hedge, and ADR-083 gave the server-side shape to mirror by symmetry. Names the three concept layers that were tangled throughout the initial development (deployment role / establishment side / ALPN-level category) so the fix is legible. Names alknet/register as a dialable entry-point ALPN (native registration, parallel to HTTP registration in OQ-58); its wire protocol is deferred to OQ-66 (blocked on OQ-58's token model). Cross-references updated across 11 existing docs (README, overview, open-questions, OQ-55, tls, hub, core, channels README/overview/ channel-client, call client-and-adapters) to reflect OQ-55 resolved and the new alknet-client crate. Architecture review passed (2 critical, 7 warnings — all addressed).
21 KiB
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 §"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:
-
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 aConnectionviaConnection::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 sharingTlsClientConfig, 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. -
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 aTlsClientConfigand passes it to its transport's connector. The dial no longer waits on the TLS config; the TLS config is shared. -
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 aConnection— 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 + produceConnection" (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:
- 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.
- 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). - 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-coreis ruled out by a cycle.AlknetClientneedsTlsClientConfigfromalknet-tls, andalknet-tlsdepends onalknet-core. PuttingAlknetClientin core createsalknet-core → alknet-tls → alknet-core— a circular dependency.alknet-tlsis the wrong scope. That crate is "TLS config + cert sharing" (TlsServerConfig/TlsClientConfig), not "dial + transport establishment." The dial callsquinn::Endpoint::connect,TcpStream::connect+TlsConnector::connect, andiroh::Endpoint::connect— transport-connection establishment, not TLS config. Putting the dial inalknet-tlswould weld transport establishment to cert config, the same conflation ADR-082 untangled on the server side.- Folding into
alknet-hub/alknet-workerwould 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
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<quinn::Endpoint>,
#[cfg(feature = "tcp")]
tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
iroh: Option<iroh::Endpoint>,
}
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<Connection, ClientDialError>;
/// 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<Connection, ClientDialError>;
/// Iroh dial. Dials `node_id` on `alpn` via the iroh endpoint. The
/// iroh path does NOT use `TlsClientConfig` — iroh has its own TLS
/// (shares the `Ed25519SecretKey`, not the rustls config —
/// ADR-087 §3). 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<Connection, ClientDialError>;
}
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 aPeerEntryand 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
PeerEntrywith 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.AlknetClientcallsTlsClientConfig::newper-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 theConnectionthe dial produces; they do not knowAlknetClientproduced it.CallCredentials/RemoteIdentity— unchanged. The credential bundleAlknetClienttakes is the existing type fromalknet-call(or moved toalknet-client/alknet-coreas 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.
AlknetClientis 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 —AlknetClientdials both; the protocol on the resultingConnectiondiffers. - The hub's
supervise_worker(hub README §"Dial") — the hub's supervision loop takes adialclosure that produces aConnection. That closure can callAlknetClient::dial_quic/dial_tcp_tlsinternally. The hub does not need to knowAlknetClientexists — the closure seam is preserved. The hub spec is updated to noteAlknetClientas 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 inAlknetClient. 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 understandAlknetClient(dial + produceConnection) 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.
AlknetClientprovides both dials; the fallback policy is a caller concern (or a futuredial_with_fallbackhelper — two-way-door). alknet/registeris 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-clientis 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/registerwire protocol is still deferred. This ADR names the ALPN and its role; the handshake protocol (token/no-token, the frames, thePeerEntrycreation, 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 —
AlknetEndpointas multi-transport accept-loop runner; the server-side shape this ADR mirrors on the client side - ADR-086 — endpoint types (native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN distinction (§2)
- ADR-087 —
TlsClientConfignot blocked on the dial seam; breaks the circular hedge; the TLS config is a prerequisite for the dial - ADR-082 —
TlsServerConfig/TlsClientConfiginalknet-tls; "iroh shares the key, not the config" - ADR-065 —
Connection::from_stream/from_bidi; the server-side generalization whose client-side analogue this ADR completes - ADR-034 — client-side verifier selection (fingerprint pin vs CA vs fail-closed)
- ADR-084 — aws-lc-rs crypto provider on all paths
- ADR-080 —
ChannelClient::from_connection(the take-overAlknetClientfeeds) - ADR-017 —
CallClient::spawn_dispatch(the take-overAlknetClientfeeds) - OQ-58 — worker registration flow (the HTTP path;
alknet/registeris the native analogue) docs/architecture/crates/channels/channel-client.md§"Relationship toAlknetClient" — the deferral this ADR resolves