Files
alktls/docs/architecture/decisions/002-tlserror-shape.md
T
glm-5.3-flash d74a27f764 phase 1: architecture spec — overview, server/client, ADR-001..006
- ADR-001: inherit the alknet TLS design as the baseline; deviations
  recorded as alktls ADRs
- ADR-002: TlsError ships the ADR-088 six-variant shape from day one
  (typed #[from] sources; NoqWrap; no string catch-all)
- ADR-003: the QUIC feature is noq (iroh's extracted fork), pre-
  consumer rename; default = [] per the lean-crate convention
  (corrects the extracted code's default = ["quinn"])
- ADR-004: complete accessors — for_tcp_tls() adopted, rustls_config()
  adopted; server accessors borrow (&self), client accessors consume
- ADR-005: identity + credentials + fingerprint types move into
  alktls; auth layer stays out
- ADR-006: eight-module layout; seed tests + integration invariant
  pins (exact nine-scheme list, client enable_early_data)
- specs: overview (transport picture, terminology), server.md (ACME
  lifecycle, invariants), client.md (verifier selection matrix, root-
  store fallback); open-questions.md promotes OQ-TLS-01..08 (all
  resolved at entry)
- Cargo.toml: quinn feature -> noq (per ADR-003); AGENTS.md aligned

Architecture review pass done: 0 critical, 2 major (ADR-002 AcmeConfig
doc comment contradiction; ADR-003 unrecorded default deviation) and
8 minors all addressed; cross-references verified against alknet ADRs,
rustls/noq/iroh sources.

Verified: cargo test, test --all-features, clippy -D warnings,
fmt --check, doc --no-deps
2026-09-10 05:37:55 +00:00

6.3 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::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:

#[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)