Checklist (all six PASS): 1. API surface == ADR-004 — every accessor signature verified verbatim 2. TlsError == ADR-002 — six variants, #[non_exhaustive], typed sources, AcmeConfig holds exactly the two config-mismatch cases 3. Invariants: all five server invariants + client 0-RTT half + fail-closed structure, each with a passing behavioral test at unit and integration level 4. Deltas vs extraction: all ADR-pinned; two surfaced divergences recorded as ADR amendments — zero un-pinned divergences remain 5. Feature hygiene: default = [] lean, tokio subset (no full), doc comments on public API, no inline // comments, no panics 6. Docs sync: ADR-002 + ADR-003 amendment notes; overview/server/client Draft → Reviewed; README carries the API-freeze lifecycle note 5 findings, all low severity, all resolved forward (table in task Notes) Verification: cargo test (81), cargo test --all-features (92), clippy -D warnings, fmt --check, doc --no-deps, publish --dry-run — all green. API FROZEN for the alknet rewrite.
6.8 KiB
status, last_updated
| status | last_updated |
|---|---|
| accepted | 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::Errorrelies onrustls_pemfilefunnelling its own non-std::error::Errorerror type intoio::Error. Confirmed at the pinned versions:rustls_pemfile2.x returnsResult<T, io::Error>from its iterator and read APIs (certs,private_key), so PEM parse failures surface asio::Error— theCertLoad(#[from] io::Error)variant is sound.VerifierBuildwrapsrustls::webpki::VerifierBuilderError(confirmed present in rustls 0.23.44:NoRootAnchors+InvalidCrl(CertRevocationListError),#[non_exhaustive]). With the ADR-088 §5 root-store fallback in place,NoRootAnchorsis 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:
#[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. Re-exported by rustls at
/// `rustls::client` (the `rustls::webpki` module is private at the
/// pinned 0.23.44; same type, public path).
#[error("building webpki verifier: {0}")]
VerifierBuild(#[from] rustls::client::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 arustls::Error),rustls_pemfile's error is not astd::error::Error, andWebPkiServerVerifier::build()returns its ownVerifierBuilderError. One enum with#[from]sources models the actual call sites. - The quinn-gated variant becomes
NoqWrap(noq-gated), renaming ADR-088'sQuinnWrapper 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 residualConfig(String)call sites (e.g.CertifiedKey::from_derstring fallbacks in the client-auth builder) map to this variant — see the refinement below.- Scope boundary holds (alknet ADR-088 §6).
TlsErroris 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;
TlsErrornever has aQuinnWrapvariant to deprecate.
Negative:
- Six variants is more surface than three; consumers must match
more arms (mitigated by
#[non_exhaustive]+ wildcard arms). rcgen::Errorin 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).
Amendment (2026-09-10, from the Phase 2 implementation): the
VerifierBuild source's reachable path at the pinned rustls 0.23.44
is rustls::client::VerifierBuilderError, not
rustls::webpki::VerifierBuilderError — the rustls::webpki module
is private at that version and the type is publicly re-exported at
rustls::client (same type; the implementation carries it that way,
with the path noted in the variant's doc comment).
References
- alknet ADR-088 — the recorded target shape and its rationale
- alknet ADR-088 §5 — the root-store fallback that keeps
NoRootAnchorsunreachable docs/research/phase-0.md§Gaps #1 — the gap analysis- ADR-001 — the inheritance baseline
- ADR-003 — the noq feature (the
NoqWraprename)