docs(arch): tighten tls/endpoint/client specs — remove connect/connect_quic, move CallCredentials to core, shed alknet-call TLS deps

Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client)
+ revised core found compile-blocking inconsistencies, stale claims, and
dep-graph contradictions. All resolved:

Critical:
- C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not
  delegated) — keeping them as thin wrappers over AlknetClient::dial_quic
  would make protocol crates depend on alknet-client, contradicting the
  dep graph. Callers compose dial + take-over (2 lines).
- C2: alknet-client feature gates now pull alknet-core/quinn +
  alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh).
- C3: rustls-native-certs + webpki-roots added to alknet-tls deps
  (always-present, not feature-gated — CA-verify path is transport-agnostic).

Warning:
- W1: CallCredentials/RemoteIdentity moved to alknet-core (from
  alknet-call) — the dial must not depend on the call protocol; not a
  two-way-door, it determines the dep graph.
- W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) —
  the code claimed a fallback that never existed; now the store is never
  empty, NoRootAnchors unreachable, containerized deployments work.
- W3: EndpointError removed entirely (BindFailed + HandlerNotFound both
  vestigial after ADR-083); shutdown() is now infallible.
- W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) —
  alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs
  entirely; CallClient becomes a pure protocol crate.

Plus: ClientError removed (only produced by removed connect); S1
(CallCredentials → ClientVerifierContext mapping + auth_token stripped
at TLS boundary documented); amendment notes on ADR-017, ADR-069,
ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index
updated.

29 files, consistency-reviewed.
This commit is contained in:
glm-5.2 committed 2026-07-16 11:33:18 +00:00
1 parent 34e3be2801
commit bf6ce957c0
29 files changed
+524 -267

No files matched your search

+2 -2
View File
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
## Applicable ADRs
@@ -81,7 +81,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015, ADR-022.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-022. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-066, not a schema-only placeholder.)
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-029.
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-066 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-014, [client-and-adapters.md](client-and-adapters.md).
@@ -114,7 +114,7 @@ operations discovered when the connection was established.
/// opened). Holds the connection's Layer 2 overlay (imported ops).
pub struct CallConnection {
/// The underlying transport Connection (from endpoint.accept,
/// CallClient::spawn_dispatch, or CallClient::connect). May be QUIC,
/// CallClient::spawn_dispatch, or AlknetClient::dial_*). May be QUIC,
/// TCP+TLS, WebTransport, SSH, or any Connection::from_stream source
/// (ADR-065).
connection: Connection,
@@ -176,10 +176,12 @@ The adapter:
6. Manages the `PendingRequestMap` for outgoing calls
The dispatch loop is **shared** with `CallClient` (ADR-017 §1): both
`CallAdapter::handle` (accept path) and `CallClient::connect` (connect path)
construct a `Dispatcher` (`protocol/dispatch.rs`) and call `run_loop` — the
dispatch half is one implementation, the connection-establishment half differs
(accept vs dial). Peer authorization flows through the existing
`CallAdapter::handle` (accept path) and `CallClient::spawn_dispatch`
(connect path — the dial is now `AlknetClient::dial_*` per ADR-089 §5;
`CallClient::connect` is removed) construct a `Dispatcher`
(`protocol/dispatch.rs`) and call `run_loop` — the dispatch half is one
implementation, the connection-establishment half differs (accept vs
dial). Peer authorization flows through the existing
`AccessControl::check(peer_identity)` — no `RemoteFilter`/`remote_safe` gate
(ADR-029 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
ADR-029 §1) to handle head→N-workers routing. See
@@ -22,11 +22,11 @@ This document specifies three components, all in `alknet-call`:
1. **`CallClient`** — takes over an established transport `Connection`
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary,
`connect` QUIC convenience); the dispatch loop is shared with the
server-side `CallAdapter` (ADR-017 §1); `CallClient` is the
connection-establishment + credential-handling half, not a parallel
protocol implementation.
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
`connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`);
the dispatch loop is shared with the server-side `CallAdapter`
(ADR-017 §1); `CallClient` is the connection-take-over half, not a
parallel protocol implementation.
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
via `services/list` + `services/schema` (already implemented in
`registry/discovery.rs`) and registers them in the connection's Layer 2
@@ -124,15 +124,13 @@ impl CallClient {
/// a transport.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
/// QUIC convenience constructor. Dials a QUIC connection to `addr`
/// on ALPN `alknet/call` (using `credentials` for the TLS handshake
/// — ADR-034 verifier selection), then calls `spawn_dispatch`.
/// Feature-gated on `quinn` (the dial is QUIC-specific). Additive
/// and two-way-door — `connect_tcp_tls`, `connect_webtransport`,
/// etc. join it as transports are added, without touching the
/// `spawn_dispatch` contract. This convenience is a thin wrapper
/// over `AlknetClient::dial_quic` (ADR-089) — a caller that needs
/// transport selection uses `AlknetClient` directly.
/// **REMOVED per ADR-089 §5.** The dial is extracted into
/// `AlknetClient` (`alknet-client`); `connect` is deleted, not
/// delegated, to avoid `alknet-call` depending on `alknet-client`
/// and to let `alknet-call` shed its TLS/transport deps entirely.
/// Callers compose `AlknetClient::dial_quic(...).await?` +
/// `CallClient::new(...).spawn_dispatch(conn)`. `ClientError` is
/// removed (it was produced only by `connect`).
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
@@ -192,19 +190,23 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
primary constructor — it takes a pre-established `Connection`,
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
dispatch task, and returns the live `CallConnection`. `connect()` is
the QUIC convenience over it: dial QUIC (feature-gated on `quinn`),
then `spawn_dispatch`. Tests use `spawn_dispatch` directly to wire
mock/loopback connections. A future `connect_tcp_tls` /
`connect_webtransport` would dial their transport and call
`spawn_dispatch` the same way. The one-way-door surface is
`spawn_dispatch`; the dial helpers are two-way-door conveniences.
**removed** per ADR-089 §5: the dial is extracted into `AlknetClient`
(`alknet-client`), and keeping a QUIC convenience constructor on
`CallClient` would make `alknet-call` depend on `alknet-client`,
contradicting the dep graph (the protocol crates are parallel to the
dial, not downstream of it). Callers compose `AlknetClient::dial_quic`
+ `spawn_dispatch` — two lines, the dial then the take-over. Tests use
`spawn_dispatch` directly to wire mock/loopback connections. The
one-way-door surface is `spawn_dispatch`; the dial lives in
`alknet-client`.
This mirrors `ChannelClient::from_connection` / `connect_quic`
(ADR-080) and is the client-side analogue of the server-side
generalization ADR-065 made. The call protocol, like the channels
protocol, is transport-agnostic — `Connection::from_stream` /
`from_bidi` (ADR-065) accept any `AsyncRead + AsyncWrite`, and
`spawn_dispatch` takes the resulting `Connection` unchanged.
This mirrors `ChannelClient::from_connection` (ADR-080; its
`connect_quic` is likewise removed per ADR-089 §5) and is the
client-side analogue of the server-side generalization ADR-065 made.
The call protocol, like the channels protocol, is transport-agnostic —
`Connection::from_stream` / `from_bidi` (ADR-065) accept any
`AsyncRead + AsyncWrite`, and `spawn_dispatch` takes the resulting
`Connection` unchanged.
#### Peer-keyed composition env (ADR-029)
@@ -244,8 +246,10 @@ attribution, filtered by the calling peer's authorization). See
### Credential sources for connections
`CallClient::connect()` takes a `CallCredentials` bundle. Credentials come
from `Capabilities` (ADR-014), never from environment variables. The three
`CallCredentials` (now in `alknet-core`, moved from `alknet-call` per
ADR-089 §5 so the dial does not depend on the call protocol) carries
the three credential dimensions (ADR-017 §7). Credentials come from
`Capabilities` (ADR-014), never from environment variables. The three
credential dimensions (ADR-017 §7):
```rust
+3 -3
View File
@@ -24,7 +24,7 @@ protocol work itself.
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary, `connect_quic` convenience; bidirectionality preserved |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
## Applicable ADRs
@@ -39,7 +39,7 @@ protocol work itself.
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary + `connect_quic` convenience; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
@@ -52,7 +52,7 @@ protocol work itself.
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary, `connect_quic` convenience. `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
@@ -55,6 +55,12 @@ impl ChannelClient {
/// it is additive over `from_connection` and is a two-way door —
/// `connect_tcp_tls`, `connect_webtransport`, etc. can be added
/// alongside it without touching the one-way-door surface.
///
/// **REMOVED per ADR-089 §5.** The dial is extracted into
/// `AlknetClient` (`alknet-client`); `connect_quic` is deleted,
/// not delegated, to avoid `alknet-channels-call` depending on
/// `alknet-client`. Callers compose `AlknetClient::dial_quic` +
/// `from_connection`. See "Relationship to `AlknetClient`" below.
pub async fn connect_quic(
addr: SocketAddr,
credentials: CallCredentials,
@@ -130,19 +136,19 @@ a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
ADR-044) — all produce a `Connection` that `from_connection` accepts
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
`connect_quic(addr, credentials)` is a **convenience** constructor — dial
QUIC, then `from_connection`. It is additive and two-way-door: transport-specific dial helpers (`connect_tcp_tls`, `connect_webtransport`, …)
join it as transports are added, none of which touch the `from_connection`
contract. The dial helper set is open-ended by design.
`connect_quic(addr, credentials)` was a **convenience** constructor —
dial QUIC, then `from_connection`. It is **removed** per ADR-089 §5:
keeping it as a thin wrapper over `AlknetClient::dial_quic` would make
`alknet-channels-call` depend on `alknet-client`, contradicting the dep
graph (the protocol crates are parallel to the dial, not downstream of
it). Callers compose `AlknetClient::dial_quic(...).await?` +
`ChannelClient::from_connection(conn).await?` — two lines, the dial
then the take-over.
The credential/verifier-selection rule (ADR-034) lives in the transport's
own dial path, not in `from_connection` — `from_connection` receives an
already-established, already-authenticated `Connection`, exactly as
`ChannelsAdapter::handle` does on the server side. The ~20 lines of
verifier-selection boilerplate each dial helper rebuilds is the known
duplicated cost of not having `AlknetClient` (OQ-55) extracted yet;
`from_connection` keeps that boilerplate on the *transport-specific dial*
side, not on the channels protocol's one-way-door surface.
The credential/verifier-selection rule (ADR-034) lives in the dial
(`AlknetClient`), not in `from_connection` — `from_connection` receives
an already-established, already-authenticated `Connection`, exactly as
`ChannelsAdapter::handle` does on the server side.
## Bidirectionality preserved
@@ -170,8 +176,8 @@ provides `AlknetClient` with three dial methods (`dial_quic` /
TCP+TLS, iroh); the take-over (`from_connection`) is
transport-agnostic. The two concerns are separated.
`connect_quic` becomes a thin wrapper over `AlknetClient::dial_quic` —
dial QUIC, then `from_connection`. A caller that needs transport
`connect_quic` is removed (see above) — `AlknetClient::dial_quic` is the
dial that feeds `from_connection`. A caller that needs transport
selection (QUIC with TCP+TLS fallback) uses `AlknetClient` directly;
the fallback policy is a caller concern. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) for the
@@ -184,7 +190,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
## Open Questions
@@ -249,7 +249,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary, `connect_quic` convenience; `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
## Open Questions
+66 -38
View File
@@ -26,7 +26,10 @@ QUIC dial inline — building a `TlsClientConfig`, constructing a
`quinn::Endpoint`, calling `connect_with`, wrapping as a `Connection`.
The dial boilerplate was duplicated, and there was no place for a
second transport's dial (TCP+TLS, iroh) to live without each protocol
client growing its own per-transport dial helper.
client growing its own per-transport dial helper. Those convenience
constructors are removed (see "Relationship to `CallClient` /
`ChannelClient`" below); `AlknetClient` is the single dial home, and
the protocol crates shed their TLS/transport deps entirely.
`alknet-client` extracts the dial the same way ADR-083 extracted the
accept loop on the server side: one type that takes pre-built transport
@@ -325,15 +328,25 @@ the alknet type level — see ADR-090 §"Two distinct SOCKS5 uses".
### Credentials
`AlknetClient`'s dials take a `CallCredentials` bundle — the existing
type from `alknet-call` (the local `TlsIdentity`, the optional
`auth_token`, and the `RemoteIdentity` for verifier selection). The
credentials come from `Capabilities` (ADR-014), never from environment
variables — the no-env-vars invariant. The assembly layer derives them
from the vault at startup and passes them to each dial. The credential
type's crate location is a two-way-door detail — see
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §"What
this does NOT change" and the Dependencies section below.
`AlknetClient`'s dials take a `CallCredentials` bundle — the shared
type from `alknet-core` (moved out of `alknet-call` so the dial does
not depend on the call protocol; see ADR-089 §5). It carries the local
`TlsIdentity`, the optional call-protocol-level `auth_token`, and the
`RemoteIdentity` for verifier selection. The credentials come from
`Capabilities` (ADR-014), never from environment variables — the
no-env-vars invariant. The assembly layer derives them from the vault
at startup and passes them to each dial.
**The `auth_token` is stripped at the TLS boundary.** `TlsClientConfig`
and `ClientVerifierContext` are TLS-level types and do not carry the
call-protocol auth token. The dial extracts the TLS-relevant fields
from `CallCredentials` — `tls_identity` → the client-cert
`local_identity`, `remote_identity` → the fingerprint-pin input to
`ClientVerifierContext` — and builds a `TlsClientConfig` from those
alone. The `auth_token` travels with the `Connection` into the
protocol take-over (`spawn_dispatch` / `from_connection`), where it is
sent as the first call-protocol frame when the protocol requires it.
This keeps `alknet-tls` free of any call-protocol coupling.
### The dialable ALPNs
@@ -387,13 +400,18 @@ let conn = client.dial_tcp_tls("hub.example", addr, b"alknet/call", &creds).awai
let call = CallClient::new(registry, idp).spawn_dispatch(conn);
```
The existing convenience constructors (`CallClient::connect`,
`ChannelClient::connect_quic`) become thin wrappers over
`AlknetClient::dial_quic` — they build an ephemeral `AlknetClient` (or
accept one), dial QUIC, and call `spawn_dispatch` / `from_connection`.
They remain for the "I just want QUIC, no `AlknetClient` wiring" case.
A caller that needs transport selection (QUIC with TCP+TLS fallback)
uses `AlknetClient` directly. See
The per-protocol QUIC convenience constructors that previously lived on
`CallClient` / `ChannelClient` (`connect` / `connect_quic`) are
**removed**. They welded the dial into the protocol crate — every
`CallClient` user transitively pulled `quinn` + `rustls` + the TLS
verifier machinery, and the convenience constructor's existence made
`alknet-call` / `alknet-channels-call` depend on `alknet-client` (or
duplicate the dial), contradicting the dep graph below. The dial is a
distinct concern from the protocol take-over; `AlknetClient` is the
single home for it. 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?`). See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §5.
### Iroh — shares the key, not the config (client side too)
@@ -509,9 +527,9 @@ implementation detail.
```toml
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn"]
quinn = ["dep:quinn", "alknet-tls/quinn", "alknet-core/quinn"]
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
iroh = ["dep:iroh"]
iroh = ["dep:iroh", "alknet-core/iroh"]
socks5 = ["dep:fast-socks5"] # enables the proxied dial paths (ADR-090)
```
@@ -520,21 +538,27 @@ dials TCP+TLS enables `tcp`. A deployment that dials iroh enables
`iroh`. A full native client (QUIC + TCP+TLS fallback + iroh) enables
all three. The `quinn` and `tcp` features pull the corresponding
features on `alknet-tls` (for `TlsClientConfig::for_quinn` /
`for_tcp_tls`). The `iroh` feature does not pull `alknet-tls` features
— iroh has its own TLS. The `socks5` feature (ADR-090) is independent
of the transport features — it enables the proxy code path that
`dial_quic` (UDP ASSOCIATE) and `dial_tcp_tls` (CONNECT) use when a
proxy is configured. Enabling `socks5` without `quinn` or `tcp` is a
no-op; enabling `quinn` + `socks5` enables proxied QUIC; `tcp` +
`socks5` enables proxied TCP+TLS. The `fast-socks5` dep is behind
`socks5`, so deployments that don't use a proxy don't pay the dep.
`for_tcp_tls`). The `quinn` and `iroh` features also pull the
corresponding features on `alknet-core` — `dial_quic` produces a
`Connection` via `Connection::from_quinn_with_alpn` and `dial_iroh`
via `Connection::from_iroh`, both of which live in `alknet-core`'s
`types.rs` behind core's `quinn` / `iroh` features (the "quinn feature
split" from ADR-083 §"The `quinn` feature split"). The `iroh` feature
does not pull `alknet-tls` features — iroh has its own TLS. The
`socks5` feature (ADR-090) is independent of the transport features —
it enables the proxy code path that `dial_quic` (UDP ASSOCIATE) and
`dial_tcp_tls` (CONNECT) use when a proxy is configured. Enabling
`socks5` without `quinn` or `tcp` is a no-op; enabling `quinn` +
`socks5` enables proxied QUIC; `tcp` + `socks5` enables proxied
TCP+TLS. The `fast-socks5` dep is behind `socks5`, so deployments that
don't use a proxy don't pay the dep.
### Dependencies
```
alknet-client
├── alknet-core (Connection, CallCredentials/RemoteIdentity if
│ moved here, Ed25519SecretKey, types)
├── alknet-core (Connection, CallCredentials, RemoteIdentity,
│ Ed25519SecretKey, types)
├── alknet-tls (TlsClientConfig — for quinn + tcp dials)
├── quinn (optional — dial_quic)
├── tokio-rustls (optional — dial_tcp_tls)
@@ -545,14 +569,16 @@ alknet-client
```
`alknet-client` depends on `alknet-tls` (for `TlsClientConfig`) and
`alknet-core` (for `Connection` and types). It does **not** depend on
`alknet-call` or `alknet-channels-call` — the dial is below the
protocol. If `CallCredentials` / `RemoteIdentity` stay in
`alknet-call`, `alknet-client` depends on `alknet-call` for the type
only; the cleaner option (moving the credential type to `alknet-core`
or `alknet-client`) keeps the dial below the protocol. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) §5 —
this is a two-way-door implementation detail.
`alknet-core` (for `Connection`, `CallCredentials`, `RemoteIdentity`,
and types). It does **not** depend on `alknet-call` or
`alknet-channels-call` — the dial is below the protocol.
`CallCredentials` and `RemoteIdentity` live in `alknet-core` (moved
from `alknet-call` per ADR-089 §5 — both the call and channels clients
need them, and the dial must not depend on the call protocol; see
ADR-089 §5). `FingerprintPinVerifier` lives in `alknet-tls` (moved from
`alknet-call` per ADR-087 §5 — it is a TLS concern, and
`TlsClientConfig::new` constructs it; moving it lets `alknet-call` shed
its direct `rustls` dep entirely).
## Crate dependencies (in the dep graph)
@@ -580,7 +606,9 @@ alknet-worker (uses AlknetClient to dial a hub)
`AlknetClient` is one producer, but a test can hand them a
`Connection::from_stream` directly. The dependency direction is:
`alknet-client → alknet-tls → alknet-core`; the protocol crates are
parallel, not downstream of the dial.
parallel, not downstream of the dial. `CallCredentials` and
`RemoteIdentity` live in `alknet-core` (not `alknet-call`), so the dial
does not depend on the call protocol for the credential type.
## Assembly layer integration
+11 -7
View File
@@ -8,13 +8,17 @@ last_updated: 2026-07-15
Shared types, auth, config, and identity for ALPN-based protocol
dispatch. Every handler crate depends on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, `IdentityProvider`, and
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`,
`EndpointError`) has been extracted to
[`alknet-endpoint`](../endpoint/README.md) (ADR-083 Amendment
2026-07-15); core no longer carries the accept-loop runner or its
transport deps (quinn, iroh, rcgen, rustls-acme). `Connection::from_quinn`
/ `from_iroh` stay in core's `types.rs` as shared constructors (gated
on core's `quinn` / `iroh` features).
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`) has
been extracted to [`alknet-endpoint`](../endpoint/README.md) (ADR-083
Amendment 2026-07-15; `EndpointError` is removed — both variants were
vestigial); core no longer carries the accept-loop runner or its
transport deps (quinn, iroh, rcgen, rustls-acme).
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` as
shared constructors (gated on core's `quinn` / `iroh` features).
`CallCredentials` and `RemoteIdentity` move to `alknet-core` (from
`alknet-call`, per ADR-089 §5) — both the call and channels clients
need them, and the dial (`alknet-client`) must not depend on the call
protocol for the credential type.
## Documents
+2 -2
View File
@@ -67,8 +67,8 @@ impl Connection {
pub fn from_quinn(conn: quinn::Connection) -> Self;
/// Construct from a quinn connection with an explicit ALPN (feature-gated
/// on quinn). Used by the client path (`CallClient::connect`) and the
/// endpoint's quinn accept loop.
/// on quinn). Used by the client path (`AlknetClient::dial_quic` per
/// ADR-089) and the endpoint's quinn accept loop.
#[cfg(feature = "quinn")]
pub fn from_quinn_with_alpn(conn: quinn::Connection, alpn: Vec<u8>) -> Self;
+5 -4
View File
@@ -5,10 +5,11 @@ last_updated: 2026-07-15
# Endpoint (moved to `alknet-endpoint`)
> **This document is deprecated.** The `AlknetEndpoint`,
> `HandlerRegistry`, and `EndpointError` types have been extracted from
> `alknet-core` into a new crate `alknet-endpoint` (ADR-083 Amendment
> 2026-07-15). The canonical spec is now
> **This document is deprecated.** The `AlknetEndpoint` and
> `HandlerRegistry` types have been extracted from `alknet-core` into a
> new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15).
> `EndpointError` is removed (both variants were vestigial). The
> canonical spec is now
> [`crates/endpoint/README.md`](../endpoint/README.md).
>
> The shared types the endpoint imports (`ProtocolHandler`,
+35 -23
View File
@@ -27,9 +27,10 @@ assembly layer does both (transports from `alknet-tls`'s
endpoint is a leaf consumer of core's shared types (it imports `auth`,
`config`, `types`; nothing in core imports from it), depended on by a
different audience (the assembly layer) than the shared types (every
handler crate). No handler crate imports `AlknetEndpoint`,
`HandlerRegistry`, or `EndpointError` — they depend on `alknet-core`
for `ProtocolHandler`, `Connection`, `AuthContext`, and types only.
handler crate). No handler crate imports `AlknetEndpoint` or
`HandlerRegistry` — they depend on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, and types only.
(`EndpointError` is removed — see below.)
## Why
@@ -89,7 +90,16 @@ impl AlknetEndpoint {
);
pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
/// Signal all owned accept loops to stop and drain in-flight handlers
/// for `drain_timeout`. Infallible — the accept loops are owned by
/// the endpoint (quinn, iroh, TCP+TLS), so there is no external
/// coordination and no bind failure path (the assembly layer binds
/// before handing pre-built transports to the endpoint via
/// `with_quinn` / `with_iroh` / `with_tcp_tls`). No-handler matches
/// are swallowed by `dispatch` (ADR-083: close + log, not an error).
/// One owner, one shutdown — no external loop coordination needed.
pub async fn shutdown(&self);
}
```
@@ -131,25 +141,25 @@ Registration is static at startup (ADR-010, OQ-04). The assembly layer
builds a `HandlerRegistry`, inserts all handlers, and passes it to
`AlknetEndpoint::new()`.
### `EndpointError`
### `EndpointError` — removed
The error type for `AlknetEndpoint::shutdown` and listener bind
failures. Lives in `alknet-endpoint` (moves with the endpoint from
core).
The endpoint previously had an `EndpointError { BindFailed(io::Error),
HandlerNotFound(Vec<u8>) }` enum. Both variants are vestigial after
ADR-083:
```rust
pub enum EndpointError {
BindFailed(io::Error),
HandlerNotFound(Vec<u8>), // ALPN string with no registered handler
}
```
- `BindFailed` — the endpoint takes pre-built, pre-bound transports
(the assembly layer does the binding); the endpoint performs no bind,
so it cannot produce a bind error.
- `HandlerNotFound` — `dispatch` swallows no-handler matches (close +
log per ADR-083), so this variant is never returned.
After ADR-083, the endpoint takes no TLS config and constructs no
transports — TLS config errors surface as `TlsError` in `alknet-tls` /
the assembly layer, not as `EndpointError`. The `TlsConfig(io::Error)`
variant that existed when the endpoint built TLS internally is removed.
`BindFailed` covers listener bind failures (quinn, iroh, TCP+TLS
`TcpListener::bind`).
The enum is removed. `shutdown()` is infallible (`async fn shutdown(&self)`,
no `Result`). If a future requirement adds a real failure path to
shutdown or dispatch, a fresh error type is cleaner than retrofitting
this one. The `EndpointError` type, its `TlsConfig` variant (already
removed by ADR-083), and the `BindFailed`/`HandlerNotFound` variants all
move out of the codebase with the endpoint extraction — none survives
into `alknet-endpoint`.
### `TcpTlsListener`
@@ -254,9 +264,11 @@ alknet-endpoint
`alknet-endpoint` depends on `alknet-core` (for `Connection`,
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`).
`HandlerRegistry` and `EndpointError` live in `alknet-endpoint` (they
move with the endpoint from core). The endpoint does **not** depend on
`alknet-tls` — it takes pre-built transports, so TLS config
`HandlerRegistry` lives in `alknet-endpoint` (it moves with the
endpoint from core). `EndpointError` is removed (both variants were
vestigial — see "`EndpointError` — removed" above). The endpoint does
**not** depend on `alknet-tls` — it takes pre-built transports, so TLS
config
construction stays at the assembly layer.
### Crate dependencies (in the dep graph)
+13 -8
View File
@@ -287,15 +287,18 @@ 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):
`from_connection` primary (ADR-080; `ChannelClient::connect_quic` is
removed per ADR-089 §5 — the dial lives in `AlknetClient`):
```rust
impl Hub {
/// Take over a pre-established channels `Connection` as a worker
/// connection. Transport-agnostic — the caller (or a transport
/// helper) produces the `Connection`. This is the primary path;
/// `connect_quic_worker` and future `connect_tcp_tls_worker` are
/// conveniences over it.
/// `connect_quic_worker` (a hub-level convenience, distinct from
/// the removed `ChannelClient::connect_quic` per-protocol
/// constructor — ADR-089 §5) and future `connect_tcp_tls_worker`
/// are conveniences over it.
pub async fn dial_worker_connection(
&self,
connection: Connection,
@@ -610,8 +613,8 @@ pub enum HubError {
NoPeerIdentity,
#[error("from_call discovery failed: {0}")]
Discovery(#[from] AdapterError),
#[error("call client error: {0}")]
Client(#[from] ClientError),
#[error("dial error: {0}")]
Dial(#[from] alknet_client::ClientDialError),
#[error("channel error (client or adapter path): {0}")]
Channel(#[from] ChannelError),
#[error("registration failed: {0}")]
@@ -654,7 +657,8 @@ alknet-hub (Hub struct deps)
├── alknet-channels-call (ChannelClient, ChannelsAdapter, ChannelManager,
│ ChannelBidiStreamSource)
├── alknet-call (CallAdapter, Dispatcher, PeerCompositeEnv,
│ from_call, FromCallConfig, AdapterError, ClientError)
│ from_call, FromCallConfig, AdapterError)
├── alknet-client (AlknetClient, ClientDialError — for outbound worker dials)
├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
├── alknet-core (IdentityProvider, Connection, OperationRegistry,
│ AuthContext)
@@ -787,7 +791,7 @@ into `CallAdapter::with_aggregated_env`.
| Peer-graph routing model | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays, `PeerRef` routing, `AccessControl`-based peer auth |
| PeerEntry and Identity.id | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable) |
| Three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Hub = role-3 `PeerEntry` (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary, `connect_quic` convenience; the dial path the hub uses |
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); the dial path the hub uses |
| Channels transport-agnostic | [ADR-071](../../decisions/071-channels-wire-format.md) | Substrate modes; `Connection::from_stream`/`from_bidi` (ADR-065) — the substrate the hub relays |
| TCP+TLS as first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `with_tcp_tls(listener, acceptor)` — TCP+TLS is owned by the endpoint, not a sibling loop; supersedes ADR-010 Am. 1 |
| Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here |
@@ -832,7 +836,8 @@ See [open-questions.md](../../open-questions.md) for full details.
## References
- [channel-client.md](../channels/channel-client.md) — `ChannelClient`
(`from_connection` / `connect_quic` — the dial path)
(`from_connection` — the take-over; `connect_quic` removed per
ADR-089 §5, dial now via `AlknetClient`)
- [channels-adapter.md](../channels/channels-adapter.md) —
`ChannelsAdapter`, `ChannelManager`, the accept path
- [channel-operations.md](../channels/channel-operations.md) —
+65 -26
View File
@@ -121,7 +121,7 @@ transports the deployment runs.
| `AcceptAnyCertVerifier` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` | `alknet-core/endpoint.rs` | `alknet-tls` (consolidates with the `alknet-call` duplicate — see client table) |
| `fingerprint.rs` | `alknet-core/fingerprint.rs` | **stays in core** (shared by server + client; client is in `alknet-call`; production code uses `sha2` + manual DER only — `rustls` is test-only. See OQ-59.) |
| `fingerprint.rs` | `alknet-core/fingerprint.rs` | **stays in core** (shared by server + client; the client-side `FingerprintPinVerifier` is now in `alknet-tls` per ADR-089 §5, so both consumers are co-located; production code uses `sha2` + manual DER only — `rustls` is test-only. See OQ-59 — the original dep-edge concern that motivated keeping `fingerprint.rs` in core is dissolved by ADR-089 §5.) |
### What moves from `alknet-call` to `alknet-tls` (client side)
@@ -137,12 +137,12 @@ extraction is the client-side analogue of the server-side
| `build_client_auth()` | `alknet-call/client/call_client.rs` | `alknet-tls` (client-auth cert resolver construction inside `TlsClientConfig::new`) |
| `select_server_verifier()` | `alknet-call/client/call_client.rs` | `alknet-tls` (ADR-034 verifier selection inside `TlsClientConfig::new`) |
| `load_platform_root_cert_store()` | `alknet-call/client/call_client.rs` | `alknet-tls` (the unknown-X.509-remote CA path inside `TlsClientConfig::new`) |
| `FingerprintPinVerifier` | `alknet-call/client/call_client.rs` | `alknet-tls` or stays in `alknet-call` (implementation detail — ADR-087 §5; `TlsClientConfig::new` constructs it either way) |
| `FingerprintPinVerifier` | `alknet-call/client/call_client.rs` | `alknet-tls` (moved — it is a TLS concern; `TlsClientConfig::new` constructs it; moving it lets `alknet-call` shed its direct `rustls` dep entirely per ADR-089 §5) |
| `Ed25519SigningKey` (client-side copy) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
| `RawKeyClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
| `NoClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` (client-side copies) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
| `CallClient::connect` | `alknet-call/client/call_client.rs` | **stays in `alknet-call`** (the dial; calls `TlsClientConfig::new` + `for_quinn()` instead of `build_quinn_client_config`) |
| `CallClient::connect` | `alknet-call/client/call_client.rs` | **removed** (ADR-089 §5 — the dial is extracted to `AlknetClient`; `CallClient` keeps only `spawn_dispatch`, shedding its TLS/transport deps) |
**Consolidation note.** `Ed25519SigningKey` and
`load_cert_chain`/`load_private_key` are currently **duplicated** across
@@ -156,11 +156,14 @@ updated to import from `alknet-tls`.
types — `StaticConfig` holds a `TlsIdentity`, and config types belong in
core. `alknet-tls` re-exports them for convenience. `fingerprint.rs` stays
in core because it's shared by both the server path (endpoint extracts
fingerprint from the client cert) and the client path (`FingerprintPinVerifier`
in `alknet-call` matches the server's cert against a pinned fingerprint).
fingerprint from the client cert) and the client path
(`FingerprintPinVerifier` — now in `alknet-tls` per ADR-089 §5 —
matches the server's cert against a pinned fingerprint).
The production code in `fingerprint.rs` uses only `sha2` and manual DER
parsing — the `rustls::sign` usage is in the test helper only. See OQ-59
for the full trade-off.
(the original dep-edge concern that motivated keeping `fingerprint.rs`
in core is dissolved by ADR-089 §5 — `FingerprintPinVerifier` moved to
`alknet-tls`, so its consumers are co-located).
### `TlsServerConfig`
@@ -319,10 +322,16 @@ present (it's the core TLS library).
```
alknet-tls
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint — re-exported)
├── rustls (ServerConfig, cert types — always present)
├── rustls (ServerConfig, ClientConfig, cert types — always present)
├── rustls-pki-types (CertificateDer, PrivateKeyDer, etc. — via rustls re-export
│ or direct dep; core lists it directly)
├── rustls-pemfile (cert/key file loading — always present)
├── rustls-native-certs (platform root cert store — always present; the
│ unknown-X.509-remote CA path in `TlsClientConfig::new`)
├── webpki-roots (built-in CA roots fallback — always present; merged
│ into the root store when the platform store is empty,
│ so a containerized deployment with no system CA bundle
│ can still verify public X.509 remotes — see ADR-088 §5)
├── rcgen (self-signed cert generation — always present)
├── ed25519-dalek (Ed25519 signing key — always present, via core)
├── sha2 (fingerprint computation — always present, via core)
@@ -334,6 +343,14 @@ alknet-tls
└── rustls-acme (optional — ACME state machine)
```
`rustls-native-certs` and `webpki-roots` are always-present deps (not
feature-gated) because the unknown-X.509-remote CA-verification path in
`TlsClientConfig::new` is needed by any client dialing a public X.509
endpoint, regardless of transport (QUIC or TCP+TLS). In the
pre-extraction code these lived in `alknet-call` behind the `quinn`
feature; the extraction (ADR-089 §5) moves them to `alknet-tls` ungated,
and `alknet-call` sheds the deps entirely.
`alknet-core` loses `rustls-pemfile`, `rcgen`, and `rustls-acme` from
its dependencies — the cert-loading, self-signed generation, and ACME
machinery move to `alknet-tls`. Core's `acme` feature
@@ -431,7 +448,7 @@ impl AlknetEndpoint {
);
pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
pub async fn shutdown(&self);
}
```
@@ -537,11 +554,29 @@ impl TlsClientConfig {
The `ClientVerifierContext` carries the inputs to ADR-034's verifier
selection (whether a `PeerEntry` exists for the remote, the expected
fingerprint). The exact struct shape is an implementation detail; the
decisions are in ADR-034. The `TlsError` variant granularity (covering
both server and client errors) is decided — see
decisions are in ADR-034. `ClientVerifierContext` is derived from
`CallCredentials` (in `alknet-core`) at the dial site — `AlknetClient`
extracts the TLS-relevant fields (`tls_identity` → `local_identity`,
`remote_identity` → fingerprint-pin input) and builds a
`ClientVerifierContext` from those. The call-protocol `auth_token` is
**stripped at the TLS boundary** — it never reaches `TlsClientConfig` or
`ClientVerifierContext`; it travels with the `Connection` into the
protocol take-over. The `TlsError` variant granularity (covering both
server and client errors) is decided — see
[ADR-088](../../decisions/088-tlserror-shape.md) and the
[`TlsError`](#tlserror) section below.
**Root store fallback (ADR-088 §5).** The unknown-X.509-remote
CA-verification path loads the platform's native root certs
(`rustls-native-certs`). If the platform store is empty (e.g. a
containerized deployment with no system CA bundle), the built-in
`webpki-roots` are merged in so the store is never empty. This makes
the `NoRootAnchors` failure mode unreachable in practice — a
containerized worker dialing a public X.509 hub succeeds without
requiring the operator to mount a CA bundle. Native-certs *load* errors
are logged, not returned (preserved behavior); the fallback guarantees
the store is non-empty regardless. See ADR-088 §5.
`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the
transport-specific dial helper — `AlknetClient::dial_quic` /
`dial_tcp_tls`, ADR-089) passes it to the transport's connector. The
@@ -642,10 +677,11 @@ arrive asynchronously and are logged (ADR-082 §"Behavior-preservation
invariants").
**Ownership.** `TlsError` lives in `alknet-tls`, owned by the crate
that produces it. It is not re-exported from `alknet-core`; core's
`EndpointError` has no `TlsConfig` variant after ADR-083 and does not
need to know about `TlsError`. The assembly layer (hub/worker) depends
on `alknet-tls` directly and gets `TlsError` from that dependency.
that produces it. It is not re-exported from `alknet-core`; `EndpointError`
is removed entirely after ADR-083 (both variants were vestigial), so
core has no endpoint error type and does not need to know about
`TlsError`. The assembly layer (hub/worker) depends on `alknet-tls`
directly and gets `TlsError` from that dependency.
## Crate dependencies (in the dep graph)
@@ -653,11 +689,12 @@ on `alknet-tls` directly and gets `TlsError` from that dependency.
alknet-tls
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
alknet-core (loses TLS setup code)
alknet-core (loses TLS setup code + endpoint)
├── (rustls — only for fingerprint.rs types, if kept)
alknet-call (client-side verifier — unchanged)
├── alknet-core (fingerprint.rs)
alknet-call (pure protocol crate — no TLS/transport deps per ADR-089 §5)
└── alknet-core (ProtocolHandler, Connection, types; CallCredentials/RemoteIdentity
moved to core per ADR-089 §5)
alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
@@ -696,12 +733,13 @@ All design decisions are documented as ADRs in
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-59** (resolved): `fingerprint.rs` stays in `alknet-core`. The
client-side `FingerprintPinVerifier` (in `alknet-call`) uses fingerprint
functions and must not depend on `alknet-tls` (which would pull TLS
setup infra into client-only deployments). The `rustls` dep in core is
narrow — production fingerprint code uses only `sha2` + manual DER; the
`rustls::sign` usage is a test helper. `alknet-tls` re-exports the
fingerprint functions for convenience.
client-side `FingerprintPinVerifier` (now in `alknet-tls` per
ADR-089 §5 — the original `alknet-call` → `alknet-tls` dep-edge
concern that motivated keeping `fingerprint.rs` in core is
dissolved). `fingerprint.rs` stays in core because `alknet-core`'s
own `Identity`/fingerprint code uses it; `alknet-tls` re-exports.
The `rustls` dep in core is narrow — production fingerprint code uses
only `sha2` + manual DER; the `rustls::sign` usage is a test helper.
- **OQ-60** (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in `alknet-endpoint` behind a `tcp` feature
as an owned endpoint transport (`with_tcp_tls`). Builder functions are
@@ -758,9 +796,10 @@ accessors (`for_quinn`, `for_tcp_tls`, `rustls_config`); iroh is the
exception (shares the key, not the config). `AlknetClient` is the dial
that feeds them — it produces a `Connection` and the protocol
take-overs (`spawn_dispatch`, `from_connection`) consume it. The
existing `CallClient::connect` / `ChannelClient::connect_quic`
convenience constructors become thin wrappers over
`AlknetClient::dial_quic`. The `alknet/register` ALPN (native
per-protocol QUIC convenience constructors (`CallClient::connect` /
`ChannelClient::connect_quic`) are **removed** per ADR-089 §5 — the
dial is centralized in `AlknetClient`, and the protocol crates shed
their TLS/transport deps. The `alknet/register` ALPN (native
registration entry point, parallel to HTTP registration in OQ-58) is
named by ADR-089; its wire protocol is deferred (OQ-66).