Files
alktls/docs/architecture/decisions/002-tlserror-shape.md
T
glm-5.3-flash 8a15978c41 generation 5: review-impl — spec-conformance gate passed, API frozen
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.
2026-09-10 15:05:01 +00:00

159 lines
6.8 KiB
Markdown

---
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<T, io::Error>` 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. 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 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).
**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
`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)