Files
alknet/docs/architecture/decisions/088-tlserror-shape.md
T
glm-5.2 bf6ce957c0 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.
2026-07-16 11:33:18 +00:00

469 lines
26 KiB
Markdown

# ADR-088: `TlsError` Shape — Single Enum, Owned by `alknet-tls`
## Status
Accepted (resolves OQ-63)
## Context
OQ-63 flagged that `TlsError` is referenced in the public signatures of
`TlsServerConfig::new`, `TlsClientConfig::new`, and `for_quinn()` (ADR-082,
ADR-087) but was never sketched. An implementer cannot write the crate
without deciding the shape: a thin wrapper around `rustls::Error` /
`io::Error` (less code, no matchable context) or a single enum with variants
per failure category (more code, the assembly layer can distinguish
"cert file missing" from "quinn rejected the rustls config" from
"verifier build failed because the root store is empty"). This ADR makes
that decision, grounded in the actual error-producing call sites and the
actual error types the dependency crates return.
### What was verified (the grounding)
The decision is grounded in two sources, not memory:
1. **The current code** — `crates/alknet-core/src/endpoint.rs`
(server-side: `build_rustls_server_config`, `TlsSetup::new`,
`TlsSetup::new_acme`, `build_quinn_server_config_from_rustls`,
`load_cert_chain`, `load_private_key`, `generate_self_signed_cert`)
and `crates/alknet-call/src/client/call_client.rs` (client-side:
`build_quinn_client_config`, `build_client_auth`,
`select_server_verifier`, `load_platform_root_cert_store`). Both
currently funnel every error into `io::Error` (server) or `String`
(client), losing all context.
2. **The dependency-crate sources** (rustls 0.23.41, rustls-pemfile 2.2.0,
rcgen 0.13.2, quinn-proto 0.11.15, rustls-acme 0.12.1, rustls-pki-types
1.14.1) — read from the cargo cache to confirm the exact error type
each call site returns, whether it implements `std::error::Error`, and
whether it is `Send + Sync + 'static`. Also confirmed: `rustls::Error`'s
`std::error::Error` impl is empty (no `source()` — the detail is in
`Display`, not a source chain); `rustls_pemfile::Error` has no
`Display` and no `Error` impl (only `From<Error> for io::Error`); the
`quinn` crate re-exports `quinn_proto::crypto` as `quinn::crypto`, so
`quinn::crypto::rustls::NoInitialCipherSuite` is the public path. The
full mapping is below.
### The actual error-producing call sites
**Server side (`TlsServerConfig::new` + `for_quinn`):**
| Call site | Error type | Notes |
|-----------|-----------|-------|
| `std::fs::read(cert_path)` / `read(key_path)` | `io::Error` | file read; cert/key load |
| `rustls_pemfile::certs()` / `private_key()` | `io::Error` | pemfile funnels its own `pemfile::Error` into `io::Error` (it does not impl `std::error::Error` — see "Gotchas") |
| "no private key found in file" | `io::Error(InvalidData)` | the `Ok(None)` case from `private_key()` |
| `rcgen::KeyPair::generate()` / `CertificateParams::self_signed()` | `rcgen::Error` | self-signed cert generation |
| `ServerConfig::builder_with_provider(...).with_safe_default_protocol_versions()` | `rustls::Error` | protocol-version / cipher-suite selection |
| `.with_single_cert(cert_chain, key)` | `rustls::Error` | key load via provider + `keys_match` |
| `RawKeyCertResolver::new(...)` | **infallible** | builds `CertifiedKey` in-memory; `ResolvesServerCert` has no construction error |
| `QuicServerConfig::try_from(rustls::ServerConfig)` (in `for_quinn`) | **`NoInitialCipherSuite`** | NOT `rustls::Error` — a distinct struct (re-exported at `quinn::crypto::rustls::NoInitialCipherSuite`, defined in `quinn_proto::crypto::rustls`); the one path where `for_quinn()` fails |
| "ACME feature not enabled but `TlsIdentity::Acme` configured" | `io::Error(Unsupported)` | the `#[cfg(not(feature = "acme"))]` guard |
| `AcmeConfig::new()` / `AcmeConfig::state()` | **infallible** | ACME state-machine construction does not fail; errors are stream events (`EventError`), logged not returned (see "ACME") |
**Client side (`TlsClientConfig::new`):**
| Call site | Error type | Notes |
|-----------|-----------|-------|
| `ClientConfig::builder_with_provider(...).with_safe_default_protocol_versions()` | `rustls::Error` | same as server-side |
| `FingerprintPinVerifier::new(...)` | **infallible** | stores the fingerprint + supported algorithms; the known-peer path |
| `WebPkiServerVerifier::builder_with_provider(...).build()` | **`VerifierBuilderError`** | `rustls::webpki::VerifierBuilderError` — `NoRootAnchors`, `InvalidCrl`; the unknown-X.509-remote path |
| `rustls::RootCertStore::add(cert)` | `rustls::Error` | adding a native root cert (maps `webpki::Error` → `InvalidCertificate(...)`) |
| `rustls-native-certs::load_native_certs()` errors | logged, not returned | native-certs load errors are logged and the load continues; the built-in `webpki-roots` are merged into the store when the platform store is empty (see §5), so the store is never empty and `NoRootAnchors` is unreachable in practice |
| Client-auth cert resolver — RawKey | **infallible** | builds `CertifiedKey` in-memory |
| Client-auth cert resolver — X.509 | `io::Error` (load) + `rustls::Error` (`CertifiedKey::from_der`) | cert/key file load + key parse |
| "ACME TLS identity is server-only; cannot be used for client auth" | `io::Error`-shape | the `TlsIdentity::Acme` as client-identity guard |
| `aws_lc_rs::default_provider()` | **infallible** | returns `CryptoProvider` by value; provider init cannot fail |
### Gotchas that drive the enum-not-wrapper decision
Three findings from the dependency-crate source make a thin wrapper
**actively wrong** for this crate:
1. **`for_quinn()` fails with `NoInitialCipherSuite`, not `rustls::Error`.**
These are two distinct, non-overlapping types from two different crates.
A thin wrapper around `rustls::Error` cannot represent the `for_quinn()`
failure at all; a thin wrapper around `io::Error` (the current
server-side pattern) erases it into "other I/O error." The assembly
layer that calls `for_quinn()` needs to distinguish "the rustls config
was built fine but quinn rejected it for lacking a TLS 1.3 AES-128-GCM
initial cipher suite" from "the rustls config build failed" — those
have different remediations (the first is a provider/cipher-suite
config issue; the second is a cert/key issue).
2. **`rustls_pemfile::Error` is not a `std::error::Error`.** It has no
`Display` impl and no `Error` trait impl — it only has
`From<Error> for io::Error`. This is why every BufRead-based pemfile
API returns `io::Error`, not `pemfile::Error`. A `TlsError` that tried
to `#[from] rustls_pemfile::Error` would not compile. The crate's
`io::Error`-returning APIs are the correct surface to wrap.
3. **`WebPkiServerVerifier::build()` returns `VerifierBuilderError`,
not `rustls::Error`.** This is the client-side unknown-X.509-remote
path (ADR-034 §3). Its variants (`NoRootAnchors`, `InvalidCrl`) are
distinct from `rustls::Error::InvalidCertificate(...)`. A thin
`rustls::Error` wrapper cannot represent "the CA root store was
empty" as a first-class failure; the enum can.
A single enum with one variant per failure category gives the assembly
layer matchable context — the one thing the current `io::Error` /
`String` funneling destroys — and correctly represents the four
distinct error types (`rustls::Error`, `NoInitialCipherSuite`,
`VerifierBuilderError`, `rcgen::Error`, plus `io::Error` for file I/O
and the ACME-disabled guard) as separate variants instead of erasing
them into one.
### What the thin wrapper would lose
The current server-side code (`endpoint.rs`) wraps everything in
`EndpointError::TlsConfig(io::Error::other(e))`. The current client-side
code (`call_client.rs`) converts everything to `String`. Both lose the
matchable context. Concretely, an operator who sees
"tls config error: No such file or directory" cannot tell from the
error alone whether the cert path was wrong, the key path was wrong, or
the ACME cache dir was wrong — and an operator who sees
"tls config error: invalid cipher suite specified" cannot tell whether
that came from `with_safe_default_protocol_versions()` (a rustls
config issue) or from `QuicServerConfig::try_from` (a quinn-wrap issue).
The enum names the category; the wrapped source keeps the detail.
## Decision
### 1. `TlsError` is a single enum with variants per failure category
`TlsError` is a `#[non_exhaustive]` enum, one variant per failure
category, each variant wrapping the underlying error as its `#[source]`.
The decision is the **variant names and their wrapped types**; the
`#[from]` vs. explicit-field style and the `AcmeConfig(String)` vs.
structured-enum choice are two-way implementation details (see "Door
type"). The shape:
```rust
/// Errors produced by `TlsServerConfig::new`, `TlsClientConfig::new`,
/// and the transport accessors (`for_quinn`, `for_tcp_tls` is infallible).
///
/// One variant per failure category. The wrapped error is the
/// `#[source]` — match on the variant for the category, inspect the
/// source for the detail.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TlsError {
/// Cert or key file could not be read or parsed.
/// Source: `io::Error` (file read + pemfile parse, which pemfile
/// funnels into `io::Error`; see ADR-088 §"Gotchas" #2).
#[error("loading cert/key material: {0}")]
CertLoad(#[from] std::io::Error),
/// Self-signed certificate generation failed.
/// Source: `rcgen::Error`.
#[error("generating self-signed cert: {0}")]
SelfSigned(#[from] rcgen::Error),
/// rustls server or client config construction failed
/// (`with_safe_default_protocol_versions`, `with_single_cert`,
/// `CertifiedKey::from_der`, `RootCertStore::add`).
/// Source: `rustls::Error`.
#[error("building rustls config: {0}")]
Rustls(#[from] rustls::Error),
/// `WebPkiServerVerifier::build()` failed (the unknown-X.509-remote
/// client path — empty root store, invalid CRL). Distinct from
/// `Rustls` because the type is distinct and the remediation is
/// different (root-store / CRL config, not cert/key config).
/// Source: `rustls::webpki::VerifierBuilderError`.
#[error("building webpki verifier: {0}")]
VerifierBuild(#[from] rustls::webpki::VerifierBuilderError),
/// `QuicServerConfig::try_from(rustls::ServerConfig)` failed — the
/// one path where `for_quinn()` can fail. The rustls config was
/// built fine; quinn rejected it (no TLS 1.3 AES-128-GCM initial
/// cipher suite in the provider). Distinct from `Rustls` because
/// the type is `NoInitialCipherSuite`, not `rustls::Error`, and the
/// remediation is provider/cipher-suite config, not cert/key.
/// Source: `quinn_proto::crypto::rustls::NoInitialCipherSuite`.
#[cfg(feature = "quinn")]
#[error("wrapping rustls config for quinn: {0}")]
QuinnWrap(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
/// ACME feature not enabled but `TlsIdentity::Acme` configured, or
/// ACME identity used where a client identity is required.
/// Not a wrapped error — a configuration mismatch detected by the
/// crate itself.
#[error("ACME configuration error: {0}")]
AcmeConfig(String),
}
```
**The variant set, in words:**
- `CertLoad(io::Error)` — file read + PEM parse (cert, key, and via the
shared `load_cert_chain`/`load_private_key` helpers). Covers the
server X.509 path, the client X.509 client-auth path, and the
"no private key found" case. `#[from] io::Error` because `io::Error` is
the type the pemfile BufRead APIs actually return.
- `SelfSigned(rcgen::Error)` — self-signed cert generation (server
`SelfSigned` identity).
- `Rustls(rustls::Error)` — rustls server or client config construction
(`with_safe_default_protocol_versions`, `with_single_cert`,
`CertifiedKey::from_der`, `RootCertStore::add`). The largest failure
surface; both server and client paths.
- `VerifierBuild(VerifierBuilderError)` —
`WebPkiServerVerifier::build()` (the unknown-X.509-remote client path).
Distinct from `Rustls` because the type is distinct and the
remediation is root-store/CRL config, not cert/key config.
- `QuinnWrap(NoInitialCipherSuite)` — `for_quinn()`'s one failure path.
Feature-gated on `quinn`. Distinct from `Rustls` because the type is
distinct and the remediation is provider/cipher-suite config.
- `AcmeConfig(String)` — "ACME feature not enabled but
`TlsIdentity::Acme` configured" (server) and "ACME identity is
server-only; cannot be used for client auth" (client). A config
mismatch detected by the crate, not a wrapped third-party error.
### 2. What is NOT a variant (and why)
These are deliberately absent because they cannot be produced by the
crate's public API:
- **`rustls_pemfile::Error`** — not a `std::error::Error` (no `Display`,
no `Error` impl); pemfile's BufRead APIs return `io::Error`. Wrapped
as `CertLoad(io::Error)`. See §"Gotchas" #2.
- **ACME `EventError` / `OrderError` / `CertParseError`** — these are
stream events from the ACME state machine, not errors returned from
`TlsServerConfig::new`. `new` spawns the state machine and returns
immediately (ADR-082 §"`async fn new` — lifecycle semantics"); the
state machine's errors surface via the event stream and are logged
(the current code logs them in the spawned task, ADR-082 §"Behavior
preservation"). They do not flow through `TlsError`. See §"ACME" below.
- **Provider init error** — `aws_lc_rs::default_provider()` is
infallible (returns `CryptoProvider` by value). There is no
`ProviderInit` variant because provider init cannot fail.
- **`rustls::server::ResolvesServerCert` / `client::ResolvesClientCert`
construction** — infallible; the resolver traits have no construction
error. `RawKeyCertResolver::new` and the client-side
`RawKeyClientCertResolver::new` build `CertifiedKey` in-memory and
cannot fail.
- **`FingerprintPinVerifier::new`** — infallible; stores the fingerprint
+ supported algorithms. The known-peer client path has no construction
error.
- **The unknown-raw-key fail-closed** — this is NOT an error returned
from `TlsClientConfig::new`. It is a `Result::Err` the *caller* gets
when *trying to connect* to an unknown raw-key remote (the
`WebPkiServerVerifier` fails the handshake at dial time). It is not in
`TlsError`'s scope — `TlsError` is for config construction, not
handshake outcomes. OQ-63's framing listed it as a "client verifier
construction" failure mode; the research corrects this: the
fail-closed is a handshake-time rejection, not a config-construction
error. See §"The fail-closed distinction" below.
### 3. `TlsError` lives in `alknet-tls` (owned by the crate that produces it)
`TlsError` is defined in `alknet-tls` and is the sole TLS error surface
for the crate. It is not re-exported from `alknet-core`. Rationale:
- `alknet-core`'s `EndpointError` no longer has a `TlsConfig` variant
after ADR-083 (the endpoint takes no TLS config; the assembly layer
builds `TlsServerConfig` and hands pre-built transports to the
endpoint). Core does not need to know about `TlsError`.
- `alknet-tls` is the crate that produces the errors; it owns the type.
Re-exporting from core would invert the ownership (core re-exporting a
type from a crate that depends on it).
- The assembly layer (hub/worker) depends on `alknet-tls` directly (it
calls `TlsServerConfig::new` / `TlsClientConfig::new`); it gets
`TlsError` from that dependency, not from core.
This resolves the subsidiary question in OQ-63 ("does `TlsError` live in
`alknet-tls` or is it re-exported from `alknet-core`?"): **`alknet-tls`,
owned by the crate that produces it.**
### 4. Feature gates
`QuinnWrap` is gated on the `quinn` feature (it wraps a quinn type).
`VerifierBuild` wraps a `rustls::webpki` type that is always present
when `rustls` is present — it is not feature-gated. The ACME
config-mismatch variant (`AcmeConfig`) is present regardless of the
`acme` feature, because the "ACME configured but feature not enabled"
guard must exist even when `acme` is off (the `#[cfg(not(feature =
"acme"))]` branch returns `AcmeConfig("ACME feature not enabled...")`).
The enum is `#[non_exhaustive]` so adding variants (e.g., a future
`TcpWrap` if `for_tcp_tls` ever gains a failure path — it does not today)
is not a breaking change.
### 5. Root store fallback — `webpki-roots` when the platform store is empty
The unknown-X.509-remote CA-verification path in `TlsClientConfig::new`
loads the platform's native root certs (`rustls-native-certs`). The
current code's comment claims a fallback to built-in `webpki-roots` when
the platform store is empty, but the code does no such thing — an empty
store produces `NoRootAnchors` from `WebPkiServerVerifier::build()`,
surfacing as `TlsError::VerifierBuild`. This is a latent bug for
containerized deployments (no system CA bundle) dialing public X.509
endpoints — a real deployment shape.
The extraction to `alknet-tls` is the moment to make the claim true.
`TlsClientConfig::new`'s CA-verify path merges the built-in
`webpki-roots` into the `RootCertStore` when the platform store comes
back empty (or unconditionally — the built-in roots are a superset of
trust, and native certs are mostly the same CAs). `webpki-roots` is an
always-present dep in `alknet-tls` (not feature-gated — the CA-verify
path is transport-agnostic). Native-certs *load* errors are still
logged, not returned (preserved behavior); the fallback guarantees the
store is non-empty regardless, so `NoRootAnchors` is unreachable in
practice. The `VerifierBuild` variant remains in `TlsError` for the
`InvalidCrl` case and forward-compatibility, but the empty-store case
is eliminated.
This is a behavior change from the current code (which silently fails on
empty stores), but it is the behavior the docs already claimed — making
the claim true, not preserving a latent bug. See ADR-089 §5 for the
broader principle: preserve behavior only when it makes sense; a claim
without code is a bug, not a contract.
### 6. ACME — errors are stream events, not `TlsError` variants
`AcmeConfig::new()` and `AcmeConfig::state()` are infallible (confirmed
in the rustls-acme 0.12.1 source). `TlsServerConfig::new`'s ACME branch
spawns the state machine and returns immediately; the state machine's
errors (`EventError` — `CertCacheLoad`, `AccountCacheLoad`, `Order`,
`NewCertParse`, etc.) arrive asynchronously through the event stream
and are logged in the spawned task (the current code's pattern, preserved
by ADR-082 §"Behavior-preservation invariants"). They do not flow through
`TlsError`.
The only ACME-related `TlsError` is `AcmeConfig(String)` — the
configuration-mismatch guard ("ACME feature not enabled but
`TlsIdentity::Acme` configured" on the server; "ACME identity is
server-only; cannot be used for client auth" on the client). This is a
synchronous config error detected at `new` time, not an ACME-protocol
error.
If a future requirement wants to surface ACME state-machine errors to
the caller (rather than log them), that is a new ADR — it changes the
`TlsServerConfig` API (the ACME handle would need an error channel) and
is out of scope for the error-shape decision.
### 7. The fail-closed distinction
OQ-63's framing listed "unknown-remote fail-closed (not an error to
return — it's a `Result::Err` the caller gets for trying to connect to
an unknown raw-key remote)" as a client verifier construction failure
mode. The research corrects this: the fail-closed is **not** a
`TlsClientConfig::new` error. It is a handshake-time rejection produced
by the verifier at dial time, not a config-construction error. The
distinction:
- `TlsClientConfig::new` builds the config. If the *inputs* are bad
(empty root store → `VerifierBuilderError`, bad fingerprint format →
not currently a construction error because `FingerprintPinVerifier`
stores the string as-is and rejects at handshake), `new` returns
`TlsError`.
- The *handshake* (dial time) can fail with `rustls::Error` (the
verifier returns `InvalidCertificate(...)`). That error flows through
the transport's connector (`quinn::Endpoint::connect_with` →
`quinn::Connection` error; `TlsConnector::connect` → `io::Error`), not
through `TlsError`.
`TlsError` is the **config-construction** error type. Handshake-time
errors are the transport's error type. This keeps `TlsError` scoped to
what `new` and `for_quinn` can actually fail on, and avoids pretending
handshake outcomes are config-construction errors. The dial seam
(ADR-089, `AlknetClient`) surfaces handshake errors as
`ClientDialError::Handshake(String)` — see the client spec's
`ClientDialError` section.
## Consequences
**Positive:**
- The assembly layer can match on `TlsError` to distinguish failure
categories — "cert file missing" (`CertLoad`) from "quinn rejected the
rustls config" (`QuinnWrap`) from "empty CA root store"
(`VerifierBuild`) from "self-signed generation failed" (`SelfSigned`).
The current `io::Error` / `String` funneling destroys this context; the
enum restores it.
- The four distinct underlying error types (`rustls::Error`,
`NoInitialCipherSuite`, `VerifierBuilderError`, `rcgen::Error`) are
represented as separate variants instead of erased into one —
correctly reflecting that they come from different crates, have
different remediations, and (in the `NoInitialCipherSuite` case) are
not even the same type as `rustls::Error`.
- `#[from]` conversions make the implementation terse (`?` propagates
`io::Error`, `rustls::Error`, `rcgen::Error`,
`VerifierBuilderError`, `NoInitialCipherSuite` directly) while keeping
the category explicit at the variant level.
- `TlsError` lives in `alknet-tls`, owned by the crate that produces it;
core's `EndpointError` is unaffected (and no longer has a `TlsConfig`
variant after ADR-083 anyway).
- `#[non_exhaustive]` lets future variants be added without breaking
downstream matches.
- The ACME boundary is clear: `TlsError::AcmeConfig` is the
config-mismatch guard; ACME state-machine errors are stream events,
logged, not `TlsError` variants.
**Negative:**
- Six variants is more code than a thin `rustls::Error` wrapper. The
trade is matchable context vs. minimal code; for a crate whose entire
purpose is shared TLS config across multiple assembly-layer call sites,
the context wins. The assembly layer is the consumer that benefits
from the distinction.
- The client-side `TlsError` coverage is slightly asymmetrical with the
server side: the server has `QuinnWrap` (quinn-specific) and
`SelfSigned` (rcgen); the client has `VerifierBuild` (webpki). This
reflects the actual asymmetry — the server wraps for quinn and
generates self-signed certs; the client builds a CA verifier. The
shared variants (`CertLoad`, `Rustls`, `AcmeConfig`) cover both. The
asymmetry is the real shape, not a missing piece.
- `rustls::Error`'s `std::error::Error` impl is empty (no `source()`),
so the source chain stops at the variant. The variant name carries the
category; the `rustls::Error`'s `Display` carries the detail. This is
the same as the current code's behavior (the detail is in the
`io::Error::other(e)` string); the enum adds the category on top.
- `NoInitialCipherSuite` has a private field — it cannot be constructed
externally (only received from quinn). This is fine for `#[from]`
(we receive it, we don't construct it), but it means tests cannot
synthesize a `QuinnWrap` variant without a real quinn rejection. The
`for_quinn` failure path is tested via integration, not unit.
## Door type
**One-way.** `TlsError` is the public API surface of `alknet-tls`. Every
assembly-layer call site (`TlsServerConfig::new`, `TlsClientConfig::new`,
`for_quinn`) returns it. Changing the variant set after consumers exist
is a breaking change to every call site's `match`. `#[non_exhaustive]`
makes *adding* variants non-breaking, but *removing* or *renaming*
variants is a breaking change. The one-way-ness is why this ADR exists
before implementation, not after.
The internal implementation (whether `CertLoad` uses `#[from] io::Error`
or an explicit `io::Error` field; whether `AcmeConfig` is a `String` or
a structured enum) is two-way — those are implementation details that
can change without breaking the variant names.
## References
- OQ-63 (resolved by this ADR) — `TlsError` shape
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` extraction
(introduces `TlsError` in signatures)
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — endpoint takes no
TLS config; `EndpointError::TlsConfig` is removed, making `TlsError`
the sole TLS error surface
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — `aws_lc_rs` provider
(infallible; no `ProviderInit` variant)
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
`TlsClientConfig` (extends `TlsError` to client-side variants)
- [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 at handshake, not at `new`)
- `crates/alknet-core/src/endpoint.rs` — the current server-side
`io::Error`-wrapping pattern (`TlsConfig(io::Error::other(e))`)
- `crates/alknet-call/src/client/call_client.rs` — the current
client-side `String`-wrapping pattern (`e.to_string()`)
- Dependency-crate sources (read from the cargo cache):
rustls 0.23.41 (`src/error.rs`, `src/builder.rs`, `src/server/builder.rs`,
`src/client/builder.rs`, `src/webpki/server_verifier.rs`,
`src/webpki/anchors.rs`), rustls-pemfile 2.2.0 (`src/lib.rs`,
`src/pemfile.rs`), rcgen 0.13.2 (`src/error.rs`, `src/certificate.rs`,
`src/key.rs`), quinn-proto 0.11.15 (`src/crypto/rustls.rs` —
`NoInitialCipherSuite`, `TryFrom<rustls::ServerConfig>`), rustls-acme
0.12.1 (`src/state.rs`, `src/acme.rs` — `EventError`, `OrderError`,
infallible `AcmeConfig::new`/`state`), rustls-pki-types 1.14.1
(`src/lib.rs`, `src/pem.rs`)