Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
557 lines
29 KiB
Markdown
557 lines
29 KiB
Markdown
# ADR-045: AlknetClient — the Native Client Dial Seam
|
|
|
|
## Status
|
|
|
|
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-012 —
|
|
the dial credential bundle is `ConnectionCredentials` (transport-level),
|
|
not `CallCredentials` (call-protocol-level); all three dial signatures
|
|
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
|
|
derived from `remote_identity`; the `auth_token` stays in the
|
|
call-protocol layer, not the dial; `CallCredentials` stays in
|
|
`alknet-call`, only `ConnectionCredentials`/`RemoteIdentity` move to
|
|
`alknet-core`; §5 further amended 2026-07-17 by ADR-012 —
|
|
`CallCredentials` is removed entirely (its `auth_token` field had no
|
|
reader); `auth_token` is a per-request payload field; the `from_call`
|
|
`credentials_auth_token` dead path is removed)
|
|
|
|
## 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-007 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<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>;
|
|
}
|
|
|
|
// NOTE: The signatures above are the ORIGINAL (pre-ADR-012) shapes.
|
|
// ADR-012 amends §3 — see the amendment note below.
|
|
```
|
|
|
|
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.
|
|
|
|
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
|
|
> `ConnectionCredentials`, not `CallCredentials`. `CallCredentials`
|
|
> couples the dial to the call protocol (its `auth_token` field is a
|
|
> call-protocol / hub-layer concept — bearer-token identity correlation
|
|
> for browsers and `alknet/register`, not a transport credential). The
|
|
> dial uses only the transport-identity dimensions (`local_identity` +
|
|
> `remote_identity`); those move to `ConnectionCredentials` in
|
|
> `alknet-core`. All three dial signatures unify on
|
|
> `&ConnectionCredentials`:
|
|
>
|
|
> ```rust
|
|
> dial_quic(addr, server_name, alpn, creds: &ConnectionCredentials) -> Connection
|
|
> dial_tcp_tls(host, addr, alpn, creds: &ConnectionCredentials) -> Connection
|
|
> dial_iroh(alpn, creds: &ConnectionCredentials) -> Connection
|
|
> ```
|
|
>
|
|
> The `node_id: iroh::NodeId` parameter on `dial_iroh` is removed — it
|
|
> is derived from `creds.remote_identity.fingerprint`
|
|
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
|
> the rustls dials use for the verifier. The consistency is now in both
|
|
> the rule (ADR-034) and the type. `CallCredentials` stays in
|
|
> `alknet-call` as the call-protocol credential bundle; the
|
|
> `auth_token` is a per-request field on `call.requested` payloads
|
|
> (set by the caller or the `from_call` forwarding handler, resolved by
|
|
> `Dispatcher::resolve_identity`), not a dial-level credential. See
|
|
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md).
|
|
|
|
### 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` are removed
|
|
|
|
The existing QUIC convenience constructors on `CallClient` and
|
|
`ChannelClient` (`connect` / `connect_quic`) are **removed**, not
|
|
delegated. Keeping them as thin wrappers over `AlknetClient::dial_quic`
|
|
would make `alknet-call` / `alknet-channels-call` depend on
|
|
`alknet-client` — contradicting the dep graph (§1: the protocol crates
|
|
are parallel to the dial, not downstream of it) and re-coupling every
|
|
`CallClient` user to `quinn` + `rustls` + the TLS verifier machinery,
|
|
the exact welding the extraction undoes. Duplicating the dial inline in
|
|
each convenience constructor would preserve the dep graph but defeats
|
|
the point of centralizing the dial.
|
|
|
|
The dial is a distinct concern from the protocol take-over.
|
|
`AlknetClient` is the single home for the dial; `CallClient` /
|
|
`ChannelClient` are the single home for the take-over. A caller that
|
|
wants the old one-liner shape composes two lines:
|
|
`client.dial_quic(...).await?` then
|
|
`CallClient::new(...).spawn_dispatch(conn)` (or
|
|
`ChannelClient::from_connection(conn).await?`). The one-way-door
|
|
surface is the `AlknetClient` dial + take-over pattern; the
|
|
per-protocol convenience constructors are gone, not retained as
|
|
two-way-door sugar.
|
|
|
|
This is a breaking change to `CallClient` / `ChannelClient`'s public
|
|
APIs. It is expected — the develop branch is a total rewrite addressing
|
|
issues not feasible to fix inline against `main`; there are no external
|
|
consumers to preserve compatibility for. The migration plan handles
|
|
the call-site updates.
|
|
|
|
**Consequence: `CallCredentials` / `RemoteIdentity` move to
|
|
`alknet-core`.** These types were in `alknet-call` because
|
|
`CallClient::connect` consumed them. With `connect` removed, the dial
|
|
(`AlknetClient`) is the consumer, and the dial must not depend on
|
|
`alknet-call` (§1). Both the call and channels clients need them (the
|
|
channels client takes `CallCredentials` for its own removed
|
|
`connect_quic`, and the dial takes them for all three dials). They move
|
|
to `alknet-core` — the shared-types crate, alongside `TlsIdentity` and
|
|
`AuthToken` which already live there. This is the cleaner of the two
|
|
options the original ADR-045 draft called a "two-way-door
|
|
implementation detail"; it is not implementation detail — it determines
|
|
the dep graph, and the dep graph requires it.
|
|
|
|
> **Amendment 2026-07-16 (ADR-012):** This consequence is superseded.
|
|
> `CallCredentials` does **not** move to `alknet-core` — it stays in
|
|
> `alknet-call` (it is the call-protocol credential bundle; its
|
|
> `auth_token` field is a call-protocol / hub-layer concept, not a
|
|
> transport credential). What moves to `alknet-core` is
|
|
> `ConnectionCredentials` (a new type carrying only the
|
|
> transport-identity dimensions: `local_identity` + `remote_identity`)
|
|
> and `RemoteIdentity`. The dial consumes `ConnectionCredentials`, not
|
|
> `CallCredentials`. All three dial signatures unify on
|
|
> `&ConnectionCredentials`. See
|
|
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md).
|
|
>
|
|
> **Further amendment 2026-07-17 (ADR-012):** `CallCredentials` is
|
|
> **removed**, not retained in `alknet-call`. The "stays in
|
|
> `alknet-call`" framing above is itself superseded: a trace of the code
|
|
> showed `CallCredentials.auth_token` had no reader (`connect()` read
|
|
> only `tls_identity` + `remote_identity`; `spawn_dispatch` takes no
|
|
> credentials; the `from_call` forwarding path's `auth_token` source was
|
|
> `OpSummary.credentials_auth_token: Option<String>`, always `None`,
|
|
> never connected to `CallCredentials.auth_token`). The original ADR-012
|
|
> rationale ("the `from_call` forwarding handler populates `auth_token`
|
|
> from `CallCredentials`") cited a code path that does not exist.
|
|
> `auth_token` is a per-request payload field — browsers send it in the
|
|
> WebSocket call payload; the HTTP gateway resolves bearer → `Identity`
|
|
> at its boundary (the call layer sees the identity, not the token);
|
|
> `Dispatcher::resolve_identity` reads `payload.get("auth_token")`.
|
|
> There is no call-protocol credential bundle. The `from_call`
|
|
> `credentials_auth_token` dead path is removed in the same pass. See
|
|
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md) §"`CallCredentials`
|
|
> is removed."
|
|
|
|
**Consequence: `FingerprintPinVerifier` moves to `alknet-tls`.** With
|
|
`connect` removed and the verifier-selection logic centralized in
|
|
`TlsClientConfig::new` (ADR-087), `FingerprintPinVerifier` has no
|
|
remaining home in `alknet-call`. It is a TLS concern (it implements
|
|
`rustls::client::danger::ServerCertVerifier`); moving it to
|
|
`alknet-tls` lets `alknet-call` shed its direct `rustls`,
|
|
`rustls-pemfile`, and `rustls-native-certs` deps entirely — `CallClient`
|
|
becomes a pure protocol crate (`{registry, identity_provider}` +
|
|
`spawn_dispatch`). See ADR-087 §5 (amended).
|
|
|
|
**Consequence: `ClientError` is removed.** The existing
|
|
`ClientError { Transport, TlsSetup, ConnectionClosed }` was produced
|
|
only by `connect` (`Transport` and `TlsSetup`) and by no current
|
|
`spawn_dispatch` path (`ConnectionClosed` is a `FrameError`/`StreamError`
|
|
variant internal to the dispatch loop, not a `CallClient` API error).
|
|
With `connect` gone, `ClientError` has no producing call site. It is
|
|
removed rather than left as a vestigial enum. If `spawn_dispatch` ever
|
|
gains a failure path, a fresh error type is cleaner than retrofitting
|
|
this one.
|
|
|
|
### 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.
|
|
- **`RemoteIdentity`** — **moved to `alknet-core`** (see §5, as amended
|
|
by ADR-012). The location changes from `alknet-call` to `alknet-core`
|
|
so the dial does not depend on the call protocol. The call and
|
|
channels clients consume it from core. (`CallCredentials` is removed
|
|
per ADR-012's 2026-07-17 amendment — it is not moved, it is deleted;
|
|
its `auth_token` field had no reader. `ConnectionCredentials` is the
|
|
new transport-level credential bundle in core, carrying
|
|
`local_identity` + `remote_identity`.)
|
|
- **The channels substrate (ADR-034)** — 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
|
|
(the removed convenience constructors each rebuilt
|
|
`TlsClientConfig::new` + their transport's connector) is centralized
|
|
in `AlknetClient`. The friction the deferral accepted is removed.
|
|
- **`alknet-call` becomes a pure protocol crate.** With `connect`
|
|
removed and `FingerprintPinVerifier` moved to `alknet-tls` (§5),
|
|
`alknet-call` sheds its direct `quinn`, `rustls`, `rustls-pemfile`,
|
|
and `rustls-native-certs` deps. `CallClient` is `{registry,
|
|
identity_provider}` + `spawn_dispatch` — no TLS, no transport. Every
|
|
handler crate that uses `CallClient` stops transitively linking the
|
|
TLS/transport stack.
|
|
- **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:**
|
|
|
|
- **Breaking change: `CallClient::connect` / `ChannelClient::connect_quic`
|
|
removed; `RemoteIdentity` / `FingerprintPinVerifier` relocated;
|
|
`CallCredentials` removed; `ClientError` removed.** Call sites that
|
|
used the convenience constructors must switch to `AlknetClient::dial_*`
|
|
+ `spawn_dispatch` / `from_connection`. Import paths for
|
|
`RemoteIdentity` change from `alknet_call` to `alknet_core`;
|
|
`CallCredentials` is removed (per ADR-012's 2026-07-17 amendment) —
|
|
callers pass `ConnectionCredentials` to the dial and, where needed,
|
|
`auth_token` as a per-request payload field. This is expected — the
|
|
develop branch is a total rewrite; there are no external consumers to
|
|
preserve compatibility for. The migration plan handles the call-site +
|
|
import updates.
|
|
- **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 `ConnectionCredentials`
|
|
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-007](007-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-043](043-channelclient.md) — `ChannelClient::from_connection`
|
|
(the take-over `AlknetClient` feeds)
|
|
- [ADR-022](022-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 |