docs(arch): TlsError shape — single enum, owned by alknet-tls (ADR-088, resolves OQ-63)

Grounded in the actual error-producing call sites (endpoint.rs server
side, call_client.rs client side) and the dependency-crate sources read
from the cargo cache (rustls 0.23.41, rustls-pemfile 2.2.0, rcgen 0.13.2,
quinn-proto 0.11.15, rustls-acme 0.12.1).

Decision: single #[non_exhaustive] enum, one variant per failure
category, owned by alknet-tls (not re-exported from core). Six variants:
CertLoad(io::Error), SelfSigned(rcgen::Error), Rustls(rustls::Error),
VerifierBuild(VerifierBuilderError), QuinnWrap(NoInitialCipherSuite)
[quinn-gated], AcmeConfig(String).

Three findings drove single-enum over thin wrapper: (1) for_quinn()
fails with NoInitialCipherSuite, not rustls::Error — a rustls::Error
wrapper cannot represent the for_quinn() failure; (2) rustls_pemfile::Error
is not a std::error::Error (no Display, no Error impl) so #[from] would
not compile — pemfile BufRead APIs return io::Error; (3)
WebPkiServerVerifier::build() returns VerifierBuilderError, not
rustls::Error — a thin wrapper cannot represent empty-CA-root-store as
a first-class failure.

Deliberately NOT variants: ACME EventError/OrderError (stream events,
logged not returned from new); unknown-raw-key fail-closed (handshake-
time rejection at dial time, not a config-construction error —
corrects OQ-63's original framing); provider init (infallible); resolver
construction (infallible).
This commit is contained in:
glm-5.2 committed 2026-07-15 09:22:24 +00:00
1 parent 5941280bca
commit 43b8179304
5 files changed
+573 -23

No files matched your search

+93 -6
View File
@@ -421,9 +421,10 @@ impl TlsClientConfig {
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).
implementation detail; the decisions are in ADR-034. 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.
`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the
transport-specific dial helper — `CallClient::connect_quic`, a future
@@ -448,6 +449,81 @@ against the expected `NodeId`. An unknown iroh remote fails closed
helper applies the same ADR-034 rule via iroh's own API; the
consistency is in the rule, not in the type.
### `TlsError`
The error type for `TlsServerConfig::new`, `TlsClientConfig::new`, and
`for_quinn()` (`for_tcp_tls` is infallible — `TlsAcceptor::new` cannot
fail). A single `#[non_exhaustive]` enum with one variant per failure
category, owned by `alknet-tls`. The shape, the rationale for
single-enum-over-thin-wrapper, and the "what is NOT a variant" list are
in [ADR-088](../../decisions/088-tlserror-shape.md); this section is
the sketch.
```rust
/// Errors produced by `TlsServerConfig::new`, `TlsClientConfig::new`,
/// and the transport accessors (`for_quinn`; `for_tcp_tls` is
/// infallible). One variant per failure category — 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 read / PEM parse. `io::Error` is the type the
/// pemfile BufRead APIs return (pemfile funnels its own non-`Error`
/// type into `io::Error` — see ADR-088 §"Gotchas" #2).
#[error("loading cert/key material: {0}")]
CertLoad(#[from] std::io::Error),
/// Self-signed cert generation (rcgen). Server `SelfSigned` path.
#[error("generating self-signed cert: {0}")]
SelfSigned(#[from] rcgen::Error),
/// rustls server or client config construction
/// (`with_safe_default_protocol_versions`, `with_single_cert`,
/// `CertifiedKey::from_der`, `RootCertStore::add`). Both paths.
#[error("building rustls config: {0}")]
Rustls(#[from] rustls::Error),
/// `WebPkiServerVerifier::build()` — the unknown-X.509-remote
/// client path (empty root store, invalid CRL). Distinct type and
/// remediation from `Rustls`.
#[error("building webpki verifier: {0}")]
VerifierBuild(#[from] rustls::webpki::VerifierBuilderError),
/// `QuicServerConfig::try_from(rustls::ServerConfig)` — the one
/// path where `for_quinn()` fails. Distinct type
/// (`NoInitialCipherSuite`, not `rustls::Error`); quinn-gated.
#[cfg(feature = "quinn")]
#[error("wrapping rustls config for quinn: {0}")]
QuinnWrap(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
/// ACME config mismatch: "feature not enabled but `Acme`
/// configured" (server), or "ACME identity is server-only; cannot
/// be used for client auth" (client). A config error, not a
/// wrapped third-party error.
#[error("ACME configuration error: {0}")]
AcmeConfig(String),
}
```
**Scope boundary (ADR-088 §6).** `TlsError` is the
**config-construction** error type — what `new` and `for_quinn` can
fail on. Handshake-time errors (the unknown-raw-key fail-closed; a
`rustls::Error::InvalidCertificate` from a rejected cert) are
**handshake outcomes**, not config-construction errors — they flow
through the transport's connector (`quinn::Endpoint::connect_with`,
`TlsConnector::connect`), not through `TlsError`. ACME state-machine
errors (`EventError`, `OrderError`, `CertParseError`) are stream events,
logged in the spawned task, not `TlsError` variants — `new` spawns the
state machine and returns immediately; the state machine's failures
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.
## Crate dependencies (in the dep graph)
```
@@ -488,6 +564,7 @@ All design decisions are documented as ADRs in
| [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 |
| [088](../../decisions/088-tlserror-shape.md) | `TlsError` shape — single enum, owned by `alknet-tls` | Single `#[non_exhaustive]` enum, one variant per failure category (`CertLoad`, `SelfSigned`, `Rustls`, `VerifierBuild`, `QuinnWrap`, `AcmeConfig`); not a thin wrapper (the `for_quinn` failure is `NoInitialCipherSuite`, not `rustls::Error`); owned by `alknet-tls`, not re-exported from core |
## Open Questions
@@ -514,9 +591,19 @@ See [open-questions.md](../../open-questions.md) for full details.
ALPNs + `alknet/channels` on the X.509/ACME config, native ALPNs on
the iroh builder. The assembly layer filters
`registry.alpn_strings()` per config.
- **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-63** (resolved): `TlsError` shape — a single
`#[non_exhaustive]` enum with one variant per failure category, owned
by `alknet-tls` (not re-exported from core). Six variants: `CertLoad`,
`SelfSigned`, `Rustls`, `VerifierBuild`, `QuinnWrap` (quinn-gated),
`AcmeConfig`. The decision is grounded in the actual error-producing
call sites and the dependency-crate sources; three findings drove
the single-enum choice over a thin wrapper (the `for_quinn()` failure
is `NoInitialCipherSuite`, not `rustls::Error`;
`rustls_pemfile::Error` is not a `std::error::Error`;
`WebPkiServerVerifier::build()` returns `VerifierBuilderError`). See
[ADR-088](../../decisions/088-tlserror-shape.md) for the full rationale
and the "what is NOT a variant" list. The `TlsError` sketch is in the
[TlsError](#tlserror) section below.
- **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.