docs(arch): break the AlknetClient circular hedge — TlsClientConfig not blocked on dial (ADR-087, resolves OQ-64)

OQ-64 and OQ-55 were linked in a circular dependency: the client-side
TLS config was deferred behind the dial seam (OQ-55), but the dial
needs the TLS config. No second transport can dial until it has a TLS
config; the TLS config was deferred until a second transport dials.
Schrödinger's code — required and not required until observed.

ADR-087 breaks the circle by separating two concerns that were
conflated as 'the same seam':

1. TlsClientConfig — rustls::ClientConfig + ADR-034 verifier selection
   + ADR-084 crypto provider. Transport-agnostic. All decisions made.
   Buildable today. A PREREQUISITE for any dial, not a consequence of it.

2. The dial (AlknetClient::dial()) — transport-specific connection
   establishment. Extracting a transport-polymorphic dial from one
   shape (QUIC) would bake QUIC in. Legitimate deferral (OQ-55,
   unchanged).

The hub makes this non-optional: a hub dials out to workers it
supervises and to other hubs (hub-as-client). The first hub deployment
(web + native) dials workers over QUIC with the worker's fingerprint
pinned. There is no 'later' for the TLS config — it is on the critical
path for the first hub and for alknet-worker.

Changes:
- ADR-087: TlsClientConfig in alknet-tls, not blocked on OQ-55
- OQ-64: resolved (yes, alknet-tls provides TlsClientConfig)
- OQ-55: amended — only the dial seam is deferred; TLS client config
  is explicitly NOT part of the deferral
- TLS README: 'Server-only (for now)' section replaced with
  TlsClientConfig section; crate is no longer server-only
- Hub README: dial/supervision section references TlsClientConfig for
  outbound connections
This commit is contained in:
glm-5.2 committed 2026-07-15 06:05:59 +00:00
1 parent 7d9b1ebad9
commit 77321a7e84
8 files changed
+502 -140

No files matched your search

+1
View File
@@ -286,6 +286,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted |
| [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted |
| [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 |
## Open Questions
+17 -9
View File
@@ -282,8 +282,12 @@ all.
#### Dial (outbound workers) — transport-agnostic
The hub dials outbound workers via `ChannelClient`, not `CallClient`.
The dial path mirrors the `from_connection` / `connect_quic` split
(ADR-080):
The hub is a client when it dials out — a hub (B) that connects to
another hub (A) is a client from A's perspective. The dial needs a
client-side TLS config (`TlsClientConfig`, ADR-087) for the outbound
connection's `rustls::ClientConfig` (verifier selection per ADR-034:
fingerprint pin for the worker's known key). The dial path mirrors the
`from_connection` / `connect_quic` split (ADR-080):
```rust
impl Hub {
@@ -299,8 +303,9 @@ impl Hub {
) -> Result<(PeerId, ChannelClient), HubError>;
/// QUIC convenience: dial a worker over QUIC, then
/// `dial_worker_connection`. Two-way door — additive over
/// `dial_worker_connection`.
/// `dial_worker_connection`. Builds a `TlsClientConfig` (ADR-087)
/// with the worker's fingerprint pinned (ADR-034), dials QUIC,
/// wraps as a channels `Connection`, calls `dial_worker_connection`.
pub async fn connect_quic_worker(
&self,
addr: SocketAddr,
@@ -315,11 +320,12 @@ 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 calls `ChannelClient::connect_quic`, then
`dial_worker_connection`. A future `connect_tcp_tls_worker` dials
TCP+TLS and calls `dial_worker_connection` the same way. The
one-way-door surface is `dial_worker_connection`; the dial helpers
are two-way-door conveniences.
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.
#### Accept (inbound workers and browsers) — transport-agnostic
@@ -780,6 +786,7 @@ into `CallAdapter::with_aggregated_env`.
| Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here |
| 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) |
## Open Questions
@@ -843,6 +850,7 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS)
- ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers)
- ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type)
- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement)
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
the first hub consumer, the concrete use case that informed this
crate
+73 -36
View File
@@ -5,12 +5,15 @@ last_updated: 2026-07-15
# alknet-tls
Shared TLS configuration and certificate management. Builds a
`rustls::ServerConfig` (or an ACME state machine + cert resolver) once,
and shares it across multiple transports — quinn, `tokio-rustls`
(TCP+TLS), and iroh — so one certificate identity serves QUIC and TCP
endpoints simultaneously. One ACME state machine, one cert, N
transports.
Shared TLS configuration and certificate management — server and
client. Builds a `rustls::ServerConfig` (or an ACME state machine +
cert resolver) once and shares it across multiple transports — quinn,
`tokio-rustls` (TCP+TLS), and iroh — so one certificate identity serves
QUIC and TCP endpoints simultaneously. Builds a `rustls::ClientConfig`
with ADR-034 verifier selection and ADR-084 crypto provider, shared
across all outbound-dialing crates (hub, worker, `CallClient`,
`ChannelClient`). One ACME state machine, one cert, N transports;
one verifier rule, N clients.
## What
@@ -388,33 +391,62 @@ behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via
cert provider, not the accept loop. This keeps `alknet-tls` focused on
TLS setup and cert sharing, not transport accept logic.
### Server-only (for now) — client-side config is a separate question
### Client-side — `TlsClientConfig` (ADR-087)
`alknet-tls` as specified here is **server-side only**:
`TlsServerConfig` and its accessors (`for_quinn`, `for_tcp_tls`,
`rustls_config`) produce `rustls::ServerConfig`-shaped outputs for
inbound listeners. There is no `TlsClientConfig`, no `for_client()`,
no client-side cert/verifier helper in this crate.
`alknet-tls` provides a client-side config alongside `TlsServerConfig`.
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.
The client side — the `rustls::ClientConfig` construction used by
`CallClient` / `ChannelClient` for outbound dials, including
[ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md)'s
verifier-selection rule (fingerprint pin for known peers, CA-verify for
unknown X.509, fail-closed for unknown raw-key) — currently lives in
`alknet-call`'s `FingerprintPinVerifier`. ADR-084 requires the client
side to use the same `aws_lc_rs` provider; today that is enforced by
**convention** (two crates independently constructing
`aws_lc_rs::default_provider()`), not by shared code.
```rust
pub struct TlsClientConfig {
config: rustls::ClientConfig,
}
Whether `alknet-tls` should grow a client-side helper (a
`TlsClientConfig` that centralizes verifier selection + provider
consistency, so `alknet-call` / a future `AlknetClient` don't each
rebuild it) is **deferred** — see OQ-64. The deferral blocks on the
`AlknetClient` dial-seam extraction (OQ-55): the client-side TLS helper
and the shared dial are the same seam, and extracting either without a
second transport's real client dial would bake QUIC in as the client
shape — the same welding ADR-065 unwound on the server side. The
provider-consistency convention (ADR-084) holds until then.
impl TlsClientConfig {
/// Build a client TLS config for the given remote identity context.
/// Applies ADR-034 verifier selection:
/// - known peer (PeerEntry present) + raw key → fingerprint pin
/// - known peer (PeerEntry present) + X.509 → fingerprint pin
/// - unknown remote + X.509 → CA verification (WebPkiServerVerifier)
/// - unknown remote + raw key → fail closed
/// Applies ADR-084 crypto provider (aws_lc_rs::default_provider()).
pub fn new(verifier_context: ClientVerifierContext) -> Result<Self, TlsError>;
}
```
The `ClientVerifierContext` carries the inputs to ADR-034's verifier
selection (whether a `PeerEntry` exists for the remote, the expected
fingerprint, the remote cert type). The exact struct shape is an
implementation detail; the decisions are in ADR-034. The full
`TlsError` variant granularity (now covering both server and client
errors) is OQ-63 (next session).
`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
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.
### Iroh — shares the key, not the config (client side too)
Iroh's client side, like its server side (above), does not consume a
`rustls::ClientConfig` — it takes an `iroh::SecretKey` and handles TLS
internally. The iroh client dial does not use `TlsClientConfig`. The
verifier selection for iroh is fingerprint-pinning by another name:
iroh's built-in TLS verifies the remote's `NodeId` (Ed25519 public key)
against the expected `NodeId`. An unknown iroh remote fails closed
(ADR-034 §3, Assumption 1 — no CA to fall back to). The iroh dial
helper applies the same ADR-034 rule via iroh's own API; the
consistency is in the rule, not in the type.
## Crate dependencies (in the dep graph)
@@ -455,6 +487,7 @@ All design decisions are documented as ADRs in
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard moves to shared `dispatch` |
| [084](../../decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs crypto provider | `rustls::crypto::aws_lc_rs::default_provider()` on all server + client config paths; matches iroh; FIPS-capable; do not switch to `ring` or process-default without a new ADR |
| [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); split ALPN lists per endpoint type (resolves OQ-62); entry-point vs. endpoint ALPN distinction |
| [087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` not blocked on dial seam | `alknet-tls` provides `TlsClientConfig` (client-side); not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case |
## Open Questions
@@ -484,12 +517,13 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-63** (open): `TlsError` shape — the error type is referenced in
public signatures but not sketched. Needs a variant-granularity
decision (single enum vs thin wrapper) before implementation.
- **OQ-64** (deferred): Client-side TLS config helper. `alknet-tls` is
server-side only as specified; the client-side `ClientConfig` +
verifier selection lives in `alknet-call`. Blocked on the
`AlknetClient` dial-seam extraction (OQ-55) — extracting the client
helper without a second transport's dial would bake QUIC in as the
client shape.
- **OQ-64** (resolved): `alknet-tls` provides `TlsClientConfig`
(ADR-087). Not blocked on the dial-seam extraction (OQ-55) — 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.
## References
@@ -507,6 +541,9 @@ See [open-questions.md](../../open-questions.md) for full details.
- `docs/architecture/decisions/086-endpoint-types-and-entry-points.md`
— three endpoint types (web/native/iroh); split ALPN lists per
endpoint type (resolves OQ-62); entry-point vs. endpoint distinction
- `docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md`
— `TlsClientConfig` (client-side); not blocked on the dial seam;
breaks the circular hedge; hub-as-client requirement
- `docs/architecture/crates/core/endpoint.md` — current endpoint design
(TLS section will be amended to point to `alknet-tls`)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
@@ -0,0 +1,293 @@
# ADR-087: `TlsClientConfig` Is Not Blocked on the Dial Seam
## Status
Accepted (resolves OQ-64)
## Context
### The circular hedge
OQ-64 and OQ-55 were linked in a way that formed a circular dependency:
- **OQ-64** said: "the client-side TLS helper is blocked on the
`AlknetClient` dial-seam extraction (OQ-55), because the TLS helper
and the dial are the same seam."
- **OQ-55** said: "the dial seam is blocked on a second transport's
real dial existing."
- A second transport's dial requires a `rustls::ClientConfig` to dial
with — which is the client-side TLS helper.
This is circular: the prerequisite (TLS config) is deferred behind the
thing that needs it (the dial). No second transport can dial until it
has a TLS config; the TLS config is deferred until a second transport
dials. The dial never arrives because the config never arrives because
the dial never arrives. Schrödinger's code — required and not required
until observed.
This is the same pattern that stalled the server-side transport
generalization before ADR-065 broke it: "we can't generalize until we
have two transports, but we can't build the second transport until we
generalize." ADR-065 broke it by separating the *Connection* (the
take-over, transport-agnostic, built now) from the *dial* (the
establishment, transport-specific, per-transport). This ADR does the
same on the client side.
### The two things that were conflated
The "same seam" claim conflated two distinct concerns:
1. **`TlsClientConfig`** — the `rustls::ClientConfig` + ADR-034
verifier selection (fingerprint pin for known peers, CA-verify for
unknown X.509, fail-closed for unknown raw-key) + ADR-084 crypto
provider (`aws_lc_rs`). This is **transport-agnostic**. The verifier
selection rule (ADR-034 §3) is keyed on `PeerEntry` presence and
remote cert type, not on transport. The crypto provider (ADR-084) is
the same on all paths. The fingerprint normalization (ADR-030 §6,
`ed25519:<hex>` / `SHA256:<hex>`) is transport-agnostic. All
decisions are made. There is nothing to discover from a second
transport's dial — the rule is the same regardless of whether the
dial is QUIC, TCP+TLS, or iroh.
2. **The dial** (`AlknetClient::dial()`) — transport-specific
connection establishment. QUIC dial (`quinn::Endpoint::connect`),
TCP+TLS dial (`TcpStream::connect` + `TlsConnector::connect`), iroh
dial (`iroh::Endpoint::connect`). Extracting a transport-polymorphic
dial from one shape (QUIC) would bake QUIC in as *the* establishment
shape — the same welding ADR-065 unwound on the server side. **This
is the legitimate deferral** (OQ-55, unchanged).
The TLS config is a **prerequisite** for the dial, not a consequence of
it. You build the `rustls::ClientConfig` first, then you dial with it.
The dial passes the config to the transport-specific connector
(`quinn::Endpoint::connect_with` takes a `ClientConfig`;
`TlsConnector::connect` takes a `ClientConfig`; iroh takes a
`SecretKey` — the one exception, see "Iroh" below). The config does not
flow *from* the dial; it flows *into* it.
### The hub makes this non-optional
A hub **has to** be a client. The hub dials out to workers it
supervises; a hub (B) that connects to another hub (A) is a client
from A's perspective. The hub README already has
`dial_worker_connection` and `supervise_worker` — those are client
operations that need a client-side TLS config. The first hub deployment
(web + native, per ADR-086) dials workers over QUIC (native endpoint,
raw key). That dial needs a `rustls::ClientConfig` with the ADR-034
verifier (fingerprint pin for the worker's known Ed25519 key).
There is no "later" for this. The first hub deployment needs the
client-side TLS config. Deferring it behind the dial-seam extraction
(OQ-55, which is genuinely blocked on a second transport) means the
first hub cannot be built — or, worse, each client rebuilds the
verifier selection + provider wiring standalone, and the duplicated
boilerplate drifts (one crate uses `aws_lc_rs`, another uses `ring`,
the convention breaks silently — exactly the ADR-084 consistency risk
the convention was supposed to prevent).
`alknet-worker` cannot exist without a client (it dials a hub). The
hub cannot exist without a client (it dials workers and other hubs).
The client-side TLS config is on the critical path for both. It is not
a future extraction; it is a present prerequisite.
## Decision
### 1. `alknet-tls` provides `TlsClientConfig`
`alknet-tls` grows a client-side config type alongside
`TlsServerConfig`:
```rust
pub struct TlsClientConfig {
config: rustls::ClientConfig,
}
impl TlsClientConfig {
/// Build a client TLS config for the given remote identity context.
/// Applies ADR-034 verifier selection:
/// - known peer (PeerEntry present) + raw key → fingerprint pin
/// - known peer (PeerEntry present) + X.509 → fingerprint pin
/// - unknown remote + X.509 → CA verification (WebPkiServerVerifier)
/// - unknown remote + raw key → fail closed
/// Applies ADR-084 crypto provider (aws_lc_rs::default_provider()).
pub fn new(verifier_context: ClientVerifierContext) -> Result<Self, TlsError>;
}
```
The `ClientVerifierContext` carries the inputs to ADR-034's verifier
selection: whether a `PeerEntry` exists for the remote, the expected
fingerprint (if known), and the remote cert type (if known). The exact
shape of this context is an implementation detail (the decisions are
in ADR-034; the struct is a bag of already-decided inputs). It is
sketched lightly here; the full variant-granularity of `TlsError` is
OQ-63 (the next session).
This is **not** the dial. `TlsClientConfig` produces a
`rustls::ClientConfig`; the caller (the transport-specific dial helper,
or `CallClient::connect_quic`, or a future `connect_tcp_tls`) passes
it to the transport's connector. The config is transport-agnostic; the
dial is not.
### 2. The dial seam (OQ-55) is unaffected
OQ-55 (the `AlknetClient` transport-polymorphic dial extraction)
remains deferred. The deferral is about the *dial* —
transport-specific connection establishment — not about the TLS config.
With `TlsClientConfig` in `alknet-tls`, each transport-specific dial
helper (`CallClient::connect_quic`, a future `connect_tcp_tls`, a
future `connect_iroh`) builds its `TlsClientConfig` and passes it to
its transport's connector. The friction (each dial helper calls
`TlsClientConfig::new` + its transport's connect) is real but narrow —
it's duplicated `TlsClientConfig::new` calls, not duplicated verifier
selection logic. When a second transport's dial exists, the dial
seam is extractable (OQ-55 unblocked); the TLS config is already
shared by then.
### 3. Iroh is the one exception (shares the key, not the config)
Iroh's client side, like its server side (ADR-082 §"Iroh: shares the
key, not the rustls config"), does not consume a `rustls::ClientConfig`
— it takes an `iroh::SecretKey` and handles TLS internally. The iroh
client dial does not use `TlsClientConfig`. The `Ed25519SecretKey`
(from `StaticConfig`, in core) feeds `iroh::SecretKey::from_bytes`
directly, same as the server side.
The verifier selection for iroh is also different: iroh's built-in TLS
verifies the remote's `NodeId` (Ed25519 public key) against the
expected `NodeId`. This is fingerprint-pinning by another name — the
`NodeId` IS the fingerprint. An unknown iroh remote fails closed (no
CA to fall back to — ADR-034 §3, Assumption 1). `TlsClientConfig` does
not cover the iroh path; the iroh dial helper applies the same
ADR-034 rule (known peer → pin, unknown → fail closed) via iroh's own
API.
### 4. `alknet-tls` is no longer "server-only"
The TLS README's "Server-only (for now)" section is removed.
`alknet-tls` provides both `TlsServerConfig` (inbound) and
`TlsClientConfig` (outbound). The server side is unchanged (ADR-082);
the client side is added by this ADR.
The provider-consistency convention (ADR-084: `aws_lc_rs` on all
paths) moves from "enforced by convention" to "enforced by
`TlsClientConfig::new`" for the rustls-consuming transports (quinn,
TCP+TLS). The iroh path uses iroh's built-in `tls-aws-lc-rs` feature
(already consistent).
### 5. The hub-as-client requirement is a first-class use case
The hub's `dial_worker_connection` / `supervise_worker` (hub README
§"Dial (outbound workers)") are client operations. They need a
`TlsClientConfig` for the outbound dial. The hub-as-client case is
not a "future use case the resolved helper must cover" — it is a
present requirement that drives the resolution. A hub that supervises
workers dials them over the native endpoint (QUIC, raw key) using a
`TlsClientConfig` with the worker's fingerprint pinned (ADR-034 §3,
known peer + raw key). A hub that connects to another hub dials it
the same way.
## What this does NOT change
- **`TlsServerConfig` (ADR-082)** — the server-side config is
unchanged. `TlsClientConfig` is a separate type, same crate.
- **The dial seam (OQ-55)** — the transport-polymorphic dial extraction
remains deferred. This ADR extracts the TLS config, not the dial.
When OQ-55 resolves, `AlknetClient::dial()` will call
`TlsClientConfig::new` + the transport-specific connector; the
config is already shared by then.
- **ADR-034 (verifier selection)** — the rule is unchanged. This ADR
centralizes its implementation in `TlsClientConfig::new` instead of
each client rebuilding it.
- **ADR-084 (crypto provider)** — the provider is unchanged. This ADR
moves enforcement from convention to code for the client side.
- **`CallClient` / `ChannelClient` take-over APIs** —
`spawn_dispatch` / `from_connection` are transport-agnostic and
decided (ADR-017, ADR-080). They take a pre-established `Connection`.
This ADR is about how the caller builds the TLS config *before*
establishing that `Connection`, not about the take-over.
- **`FingerprintPinVerifier`** — the existing verifier in
`alknet-call` is the current implementation of ADR-034's
fingerprint-pin path. `TlsClientConfig::new` centralizes the
verifier construction (including the CA-verify and fail-closed
paths that `FingerprintPinVerifier` does not cover). The
`FingerprintPinVerifier` type may move to `alknet-tls` or stay in
`alknet-call` and be constructed by `TlsClientConfig::new` — an
implementation detail, not an architecture decision.
## Consequences
**Positive:**
- The circular dependency is broken. `TlsClientConfig` is buildable
today; the dial seam (OQ-55) is no longer blocking it. A second
transport's dial can be built using `TlsClientConfig` + the
transport's connector, without waiting for the dial-seam extraction.
- The hub-as-client requirement is met. The hub's
`dial_worker_connection` / `supervise_worker` use
`TlsClientConfig::new` for the outbound dial's TLS config. The first
hub deployment (web + native) can dial workers over QUIC with the
worker's fingerprint pinned.
- `alknet-worker` is unblocked on the TLS front. A worker dials a hub
using `TlsClientConfig::new` + the transport-specific connector. The
dial seam (OQ-55) is about extracting the shared dial, not about
blocking the worker from dialing.
- Provider consistency (ADR-084) is enforced by code, not convention,
for the client side. `TlsClientConfig::new` uses
`aws_lc_rs::default_provider()`; every client that uses it gets the
right provider. The convention-based risk (one crate drifting to
`ring`) is removed.
- The duplicated boilerplate (each client rebuilding verifier
selection + provider wiring) is centralized. `TlsClientConfig::new`
is the single point where ADR-034's rule and ADR-084's provider are
applied.
**Negative:**
- `alknet-tls` grows a client-side type. The crate is no longer
"server-only." This is correct — the crate's purpose is shared TLS
config, and the client side is shared across all outbound-dialing
crates (hub, worker, `CallClient`, `ChannelClient`).
- The iroh client path does not use `TlsClientConfig`. This is
unavoidable — iroh has its own TLS. The iroh dial helper applies the
same ADR-034 rule via iroh's API. The consistency is in the rule,
not in the type.
- `TlsError` (OQ-63) now covers both server and client errors. The
variant granularity is slightly larger (client-side variants:
verifier construction, provider init, unknown-remote fail-closed).
OQ-63 is the next session and will account for both.
## Door type
**One-way.** `TlsClientConfig` as the shared client-side TLS config in
`alknet-tls` is structural — every outbound-dialing crate depends on
it. Reversing would mean re-distributing verifier selection + provider
wiring across crates, reintroducing the convention-based consistency
risk. The `TlsClientConfig::new` signature (takes a verifier context,
returns a `rustls::ClientConfig`) is one-way — changing it after
consumers exist is a rewrite. The internal implementation (how the
verifier context struct is shaped, how `FingerprintPinVerifier` relates
to the CA-verify path) is two-way.
## References
- OQ-64 (resolved by this ADR) — should `alknet-tls` provide a
client-side TLS config helper?
- OQ-55 (unaffected — the dial seam remains deferred; this ADR
extracts the TLS config, not the dial)
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §3 —
verifier selection rule (known peer → fingerprint pin; unknown X.509
→ CA verify; unknown raw-key → fail closed)
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
provider on all paths
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig`
(server-side; this ADR adds the client-side analogue)
- [ADR-065](065-connection-from-stream-generic-single-stream.md) — the
server-side precedent: separate the take-over (transport-agnostic,
built now) from the dial (transport-specific, per-transport). This
ADR is the client-side analogue.
- [ADR-086](086-endpoint-types-and-entry-points.md) — the hub composes
endpoint types and dials workers (hub-as-client)
- OQ-63 — `TlsError` shape (next session; now covers both server and
client variants)
- `docs/architecture/crates/hub/README.md` §"Dial (outbound workers)" —
the hub-as-client operations that need `TlsClientConfig`
+9 -13
View File
@@ -185,7 +185,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis
|----|-------|--------|------|-----|
| [OQ-62](questions/062-alpn-list-sharing-two-config-hub.md) | Does a Hub Pass the Same ALPN List to Both `TlsServerConfig`s? | resolved | one | high |
| [OQ-63](questions/063-tlserror-shape.md) | `TlsError` Shape | open | one | high |
| [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | deferred(scope) | two | med |
| [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | resolved | one | high |
| [OQ-65](questions/065-websocket-carrying-channels.md) | Should WebSocket Carry the Channels Protocol (Not Just the Call Protocol)? | open | one | med |
## Deferred / Blocked
@@ -294,17 +294,13 @@ filtering the tables above.
### OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper?
- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The
client-side TLS helper and the shared dial are the same seam — both
answer "how does an outbound connection build its
`rustls::ClientConfig` + select a verifier (ADR-034) + dial." The
blocking condition is the same as OQ-55: a second transport's real
client dial existing (TCP+TLS, SSH raw-TCP, HTTP-wrapped call), so the
transport-polymorphic client+TLS seam is extractable from two
*different* transport implementations, not one QUIC shape. Until then,
`alknet-tls` is server-side only; the client side lives in
`alknet-call`'s `FingerprintPinVerifier`, with provider consistency
(ADR-084) enforced by convention.
- **Priority**: medium
- **Resolved** (ADR-087): `alknet-tls` provides `TlsClientConfig`. Not
blocked on the dial-seam extraction (OQ-55) — the TLS config is a
prerequisite for the dial, not a consequence of it. The circular
hedge (TLS config deferred behind the dial, dial needs the TLS
config) is broken. The hub-as-client requirement makes it a
prerequisite for the first hub deployment. See
[ADR-087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) and
[OQ-64](questions/064-client-side-tls-helper.md).
- **Full file**: [OQ-64](questions/064-client-side-tls-helper.md)
+2 -1
View File
@@ -97,7 +97,7 @@ alknet-vault (standalone — foundational to ACL: key derivation, identity)
│ alknet-core ProtocolHandler, endpoint (multi-transport accept loop),
│ │ Connection, BidiStreamSource, AuthContext, IdentityProvider,
│ │ StaticConfig, DynamicConfig
│ ├── alknet-tls TlsServerConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082)
│ ├── 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
@@ -370,6 +370,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT |
| [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 |
## Open Questions
@@ -19,41 +19,62 @@
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 — ~20 lines, transport-agnostic in
rule. But the *dial* is transport-specific, and we have one of ~5 shapes
implemented (QUIC; the others being HTTP, TCP+TLS, WebTransport, raw
TCP). Extracting a QUIC-shaped connector to core and naming it
`AlknetClient` would bake QUIC in as *the* establishment shape — the
same welding ADR-065 unwound on the server side, repeated on the client
side. The dial is transport-polymorphic; the shared rule is narrow. Until
a second transport's dial exists, the seam between "dial + TLS" (per-
transport) and "spawn the dispatcher" (per-crate) is not extractable from
two real shapes — it's guessable from one.
`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, and each transport-specific dial helper. `CallClient`'s
transport-agnostic take-over (`spawn_dispatch`) and `ChannelClient`'s
transport-agnostic take-over (`from_connection`, ADR-080) are decided;
`connect_quic` dials QUIC and calls `from_connection`. The SSH crate's
TCP client, the HTTP call client, a `connect_tcp_tls` / `connect_webtransport` helper — each builds its own dial standalone. Core
already permits all of this — `Connection::from_stream` / `from_bidi`
(ADR-065) handles the non-QUIC transport on the server side, and nothing
prevents a client from constructing a `Connection` the same way after
its own transport-specific dial. The friction is duplicated boilerplate
(each dial helper rebuilds verifier selection), not a missing
capability and not a QUIC-welded client API. The bidirectionality
criterion (a crate needs a Client type when (a) the endpoint has
protocol-level authority — e.g., channels' id allocation — or (b) the
protocol needs a reliable establishment interface) is met by each crate
independently; `AlknetClient` is the eventual *shared dial+TLS seam*,
not a prerequisite for any single client to exist.
- **Cross-references**: ADR-034 (verifier selection — currently in
`CallClient`, would move to the extracted seam when this is resolved),
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
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
`docs/research/alknet-channels/phase-0-findings.md` (the research-scope
question this core-scope OQ carries forward).
@@ -1,55 +1,60 @@
# OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper?
- **Origin**: `docs/architecture/crates/tls/README.md` (the "Server-only
for now" section flags this as deferred); ADR-084 (requires the
for now" section flagged this as deferred); ADR-084 (requires the
client-side `rustls::ClientConfig` to use the same `aws_lc_rs`
provider — currently enforced by convention, not shared code).
- **Status**: deferred(scope)
- **Door type**: two-way (adding a client helper to `alknet-tls` is
additive; the risk is not the addition but *extracting it
prematurely* and baking in a QUIC-shaped client — see Blocking on)
- **Priority**: medium
- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The
client-side TLS helper and the shared dial are the same seam: both
answer "how does an outbound connection build its
`rustls::ClientConfig` + select a verifier (ADR-034) + dial the
transport." Extracting the TLS helper without a second transport's
real client dial existing would bake the QUIC client's shape into a
shared helper — the same welding ADR-065 unwound on the server side.
The blocking condition is the same as OQ-55: a second transport's
real dial (TCP+TLS, SSH raw-TCP, HTTP-wrapped call) existing, so the
transport-polymorphic client+TLS seam is extractable from two
*different* transport implementations.
- **Resolution**: Not yet decidable. `alknet-tls` is server-side only
as specified. The client side — `rustls::ClientConfig` construction +
ADR-034 verifier selection (fingerprint pin for known peers, CA-verify
for unknown X.509, fail-closed for unknown raw-key) — lives in
`alknet-call`'s `FingerprintPinVerifier` today. The
provider-consistency requirement (ADR-084: `aws_lc_rs` on all paths)
is enforced by convention (two crates independently constructing
`aws_lc_rs::default_provider()`) until this OQ is resolved.
provider — previously enforced by convention, not shared code).
- **Status**: resolved
- **Door type**: one-way (`TlsClientConfig` as the shared client-side
TLS config in `alknet-tls` is structural — every outbound-dialing
crate depends on it. Reversing would re-distribute verifier selection
+ provider wiring across crates.)
- **Priority**: high (upgraded from medium — the hub-as-client
requirement makes this a prerequisite for the first hub deployment,
not a future extraction)
- **Resolution**: **Yes. `alknet-tls` provides `TlsClientConfig`.** It
is not blocked on the dial-seam extraction (OQ-55).
What does NOT block on this: each client (`CallClient`,
`ChannelClient`) building its own `ClientConfig` standalone with the
matching provider. The friction is duplicated boilerplate (each
client rebuilds verifier selection + provider wiring), not a missing
capability and not a QUIC-welded client API. The
transport-agnostic take-over (`CallClient::spawn_dispatch`,
`ChannelClient::from_connection` — ADR-080) is decided and is not
the thing being deferred; only the shared *client TLS config helper*
is.
The previous deferral linked OQ-64 and OQ-55 as "the same seam,"
creating a circular dependency: the TLS config is deferred behind the
dial, but the dial needs the TLS config. The circle is broken by
separating the two concerns:
Note on the hub-as-client case: a hub (A) that dials another hub (B)
uses a client to do so — from B's perspective A is a worker. The
bidirectionality of the call and channels protocols means both sides
can be both hub and worker within a connection. This does not change
the blocking condition: the shared client TLS helper is still about
the *dial*, regardless of whether the dialer is a hub, a worker, or a
hub-worker. The hub-as-client case is a use case that the resolved
helper must cover, not a reason to resolve it now.
- **Cross-references**: OQ-55 (the `AlknetClient` dial-seam extraction
— this OQ's blocking condition), ADR-034 (verifier selection — the
rule the helper would centralize), ADR-084 (provider consistency —
the convention that holds until this is resolved), ADR-065 (the
server-side transport generalization this OQ's deferral avoids
preempting on the client side)
1. **`TlsClientConfig`** — the `rustls::ClientConfig` + ADR-034
verifier selection + ADR-084 crypto provider. Transport-agnostic.
All decisions are made. Buildable today. It is a **prerequisite**
for any dial, not a consequence of it.
2. **The dial** (`AlknetClient::dial()`) — transport-specific
connection establishment. Extracting a transport-polymorphic dial
from one shape (QUIC) would bake QUIC in. **Legitimate deferral
(OQ-55, unchanged).**
`TlsClientConfig::new` takes a verifier context (the inputs to
ADR-034's rule: `PeerEntry` presence, expected fingerprint, remote
cert type) and returns a `rustls::ClientConfig`. The caller
(transport-specific dial helper, `CallClient::connect_quic`, a future
`connect_tcp_tls`) passes it to the transport's connector. Iroh is
the exception — it has its own TLS and does not consume a
`rustls::ClientConfig`; the iroh dial helper applies the same
ADR-034 rule via iroh's `NodeId` verification API.
The hub makes this non-optional: a hub dials out to workers it
supervises and to other hubs (hub-as-client). The hub's
`dial_worker_connection` / `supervise_worker` need a
`TlsClientConfig` for the outbound dial. The first hub deployment
(web + native) dials workers over QUIC with the worker's fingerprint
pinned. There is no "later" — it is on the critical path for the
first hub and for `alknet-worker`.
See [ADR-087](../decisions/087-tlsclientconfig-not-blocked-on-dial.md)
for the full decision, including the circular-hedge analysis, the
hub-as-client requirement, and the iroh exception.
- **Cross-references**: [ADR-087](../decisions/087-tlsclientconfig-not-blocked-on-dial.md)
(the decision), [ADR-034](../decisions/034-outgoing-only-x509-and-three-peer-roles.md)
§3 (verifier selection — the rule `TlsClientConfig::new`
centralizes), [ADR-084](../decisions/084-aws-lc-rs-crypto-provider.md)
(provider consistency — enforced by code, not convention, for the
client side), [ADR-082](../decisions/082-alknet-tls-extraction.md)
(`TlsServerConfig` — the server-side analogue), OQ-55 (the dial seam
— remains deferred; this OQ's resolution does not affect it),
OQ-63 (`TlsError` shape — now covers both server and client variants)