--- status: accepted last_updated: 2026-09-10 --- # ADR-002: `TlsError` — the ADR-088 six-variant shape from day one ## Status Accepted (2026-09-10) ## Context The extracted code (`crates/alknet-tls/src/lib.rs`) carries a simplified 3-variant `TlsError`: `Config(String)`, `Io(io::Error)`, `Cert(String)`. alknet ADR-088 recorded the target shape — a single `#[non_exhaustive]` enum, one variant per failure category, typed `#[from]` sources — but it was never implemented; the extracted code folds every typed failure into `Config(e.to_string())` strings, losing the `#[source]` chain and the category distinction. Phase 0 identified this as gap #1, the highest-value gap closure: this crate is pre-consumer, so the enum shape is still a two-way door; once the alknet rewrite consumes it, it is one-way. Two mechanics from ADR-088's "Gotchas" needed verification before pinning the shape: - `CertLoad`'s `#[from] io::Error` relies on `rustls_pemfile` funnelling its own non-`std::error::Error` error type into `io::Error`. Confirmed at the pinned versions: `rustls_pemfile` 2.x returns `Result` from its iterator and read APIs (`certs`, `private_key`), so PEM parse failures surface as `io::Error` — the `CertLoad(#[from] io::Error)` variant is sound. - `VerifierBuild` wraps `rustls::webpki::VerifierBuilderError` (confirmed present in rustls 0.23.44: `NoRootAnchors` + `InvalidCrl(CertRevocationListError)`, `#[non_exhaustive]`). With the ADR-088 §5 root-store fallback in place, `NoRootAnchors` is unreachable in practice — the variant exists for the builder API's completeness, not a reachable failure path. ## Decision `TlsError` ships as the alknet ADR-088 shape, unmodified: ```rust #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum TlsError { /// Cert or key file read / PEM parse. `rustls_pemfile` funnels its /// own error type into `io::Error`, so one `io::Error` source covers /// the whole loading path. #[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`). #[error("building rustls config: {0}")] Rustls(#[from] rustls::Error), /// `WebPkiServerVerifier::builder(_with_provider)..build()` — the /// unknown-X.509-remote client path. #[error("building webpki verifier: {0}")] VerifierBuild(#[from] rustls::webpki::VerifierBuilderError), /// QUIC config wrapping — the one path where `for_noq()` fails /// (`NoInitialCipherSuite`, not a `rustls::Error`). noq-gated. #[cfg(feature = "noq")] #[error("wrapping rustls config for noq: {0}")] NoqWrap(#[from] noq_proto::crypto::rustls::NoInitialCipherSuite), /// Config-mismatch errors that are not wrapped third-party /// errors: ACME feature not enabled but `Acme` configured /// (server), or `Acme` identity used for client auth. A config /// error, not a wrapped third-party error. #[error("TLS config error: {0}")] AcmeConfig(String), } ``` Notes on the shape, per ADR-088's rationale: - **Single enum, not a thin wrapper.** ADR-088's three findings stand: the QUIC-wrap failure is `NoInitialCipherSuite` (not a `rustls::Error`), `rustls_pemfile`'s error is not a `std::error::Error`, and `WebPkiServerVerifier::build()` returns its own `VerifierBuilderError`. One enum with `#[from]` sources models the actual call sites. - **The quinn-gated variant becomes `NoqWrap`** (noq-gated), renaming ADR-088's `QuinnWrap` per ADR-003. Same failure type name (`NoInitialCipherSuite`), different crate path. - **`AcmeConfig(String)` keeps string payloads.** The two Acme mismatches are genuinely configuration mistakes, not wrapped upstream errors; there is no third-party error type to preserve. The extracted code's residual `Config(String)` call sites (e.g. `CertifiedKey::from_der` string fallbacks in the client-auth builder) map to this variant — see the refinement below. - **Scope boundary holds (alknet ADR-088 §6).** `TlsError` is the config-construction error type. Handshake outcomes (a rejected cert, the unknown-raw-key fail-closed) flow through the transport's connector; ACME state-machine runtime errors are stream events logged in the spawned task. No handshake variants, ever. - **`#[non_exhaustive]` from day one.** The enum is crate-local today, but the rewrite compiles against it; new variants are additive for consumers that match with a wildcard arm. **Refinement over the extracted code:** the extracted code uses `TlsError::Config(e.to_string())` at `rustls::Error` call sites that ADR-088 assigns to `Rustls` (e.g. `with_single_cert`, `CertifiedKey::from_der` — both return `rustls::Error`). This crate maps those call sites to the typed variants. The only genuinely stringy sites are the Acme mismatches, which land in `AcmeConfig` (matching the sketch above — `AcmeConfig` holds exactly the two config-mismatch cases, nothing else). No `Config(String)` catch-all survives — a variant-per-category enum with a string catch-all would reintroduce the fold this ADR removes. ## Consequences **Positive:** - `#[source]` chains survive for programmatic inspection (the rewrite's config plumbing can distinguish a missing file from a bad PEM from a bad config without string parsing). - The crate ships the recorded target shape — no later migration. - The noq rename rides along; `TlsError` never has a `QuinnWrap` variant to deprecate. **Negative:** - Six variants is more surface than three; consumers must match more arms (mitigated by `#[non_exhaustive]` + wildcard arms). - `rcgen::Error` in the public API couples the error type to rcgen's error churn (accepted — rcgen 0.13 is stable and the coupling is what ADR-088 chose). ## References - alknet ADR-088 — the recorded target shape and its rationale - alknet ADR-088 §5 — the root-store fallback that keeps `NoRootAnchors` unreachable - `docs/research/phase-0.md` §Gaps #1 — the gap analysis - ADR-001 — the inheritance baseline - ADR-003 — the noq feature (the `NoqWrap` rename)