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

+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`