Error model
Two levels: typed errors for control flow, one value type for the operator.
What an error must contain is in the spec’s error and remedy model. This is how it is built in Rust.
The two levels
Section titled “The two levels”Typed errors — a thiserror enum per subsystem, named Failure, used for
matching and control flow. ports::process::Failure, ports::docker::Failure,
ports::service::Failure.
One operator-facing value — error::Problem. What happened, what it means,
what to do, and where to look, as fields:
pub struct Problem { pub code: Code, pub severity: Severity, pub state: State, pub summary: String, // what happened pub meaning: String, // what it means for them pub remedies: Vec<Remedy>, // what to do pub detail: Option<String>, // where to look pub cause: Option<Box<Problem>>, pub amiss: Amiss, // whose side it lies on}amiss is the one field that does not travel. Naming is a thing the request
asked for that there is no such thing as; Asking is a request wrong in itself;
Held is other work holding what the request needed, such as another operation’s
claim on the stack; Answering — the default — is this product’s own failure. It
is set where the problem is raised, because that is the only place that knows, and
each surface says it in its own terms: the web answers 404, 400, 409 or 500,
and the command line has its exit codes. Writing it into the document as well would
be the same fact stated twice, which is two things to keep agreeing.
A refusal the web surface makes before any command runs — who is asking, where
from, what the request carried — is a problem as well, raised by
Refusal with its own status: four
of them share 403, and only the code tells a client which one it met. The contract
lists those codes so that a client generates the list it branches on.
Nothing else distinguishes those four. severity is how much a problem matters
and state is whether a remedy exists — no such word and the engine being down
agree on both, which is why every refused read once answered 500.
The bridge is one trait:
pub trait Diagnose { fn problem(&self) -> Problem;}A problem without a remedy will not compile
Section titled “A problem without a remedy will not compile”Problem::new takes a Remedy as a required argument. There is no constructor
that produces an empty remedies.
For the case where nothing is known, Problem::unknown sets State::Unknown and
attaches the support-bundle escalation, so the four parts still hold. Admitting
ignorance costs the operator a bundle; confident wrong guidance costs them an
afternoon and costs us the trust that made them believe it.
This is the whole trick. “Every error carries a remedy” is a rule that erodes one message at a time when it is a convention, and cannot erode when it is an argument.
Why the coverage gate is the exhaustiveness proof
Section titled “Why the coverage gate is the exhaustiveness proof”Rust cannot enumerate enum variants, so the obvious test — “every variant of every error produces a remedy” — has no way to iterate. The usual answer is a hand-maintained list, which rots.
The 100% line coverage gate on applicable code does it instead. Every match arm
in every Diagnose implementation must execute in some test for the gate to
pass, and each of those arms constructs a Problem, which cannot be constructed
without a remedy. A new variant with no test fails the gate, not review.
Each error module also carries an explicit sweep over all its variants, asserting the message names its subject and the problem carries a remedy. The gate proves the arms ran; the sweep proves they said something useful.
Writing assertions the gate can reach
Section titled “Writing assertions the gate can reach”Two patterns leave lines no passing test can cover, and both look harmless:
let Ok(value) = thing() else { unreachable!("cannot happen") }; // the else armassert!(cond, "{} is wrong", path.display()); // the argumentA let … else needs an arm for the case that cannot happen, and an argument
expression in a failure message only evaluates when the assertion fails. Prefer
comparing whole values, and inline captures:
assert_eq!(thing().ok(), Some(expected));assert!(cond, "{path:?} is wrong");A third is subtler: a closure that only runs on failure.
std::fs::write(path, text).map_err(|err| Failure::NotWritten { .. })?; // closureif let Err(err) = std::fs::write(path, text) { return Err(..) } // no closureA closure is a function of its own as far as coverage is concerned, and one that
only runs when something fails is a symbol no passing test reaches. map_err is
fine where a test triggers the failure; where it cannot, prefer if let Err.
unwrap_or_else(|| …) has the same shape — use unwrap_or(…) when the fallback
is cheap.
A fourth is not about assertions at all. A block whose last statement is an
await never has its closing brace marked executed.
if let Some(mut lines) = opened { // the `}` below is uncoverable while let Some(line) = lines.recv().await { … }}
let mut lines = opened.unwrap_or(closed); // no wrapping block, no orphan bracewhile let Some(line) = lines.recv().await { … }Build the fallback eagerly and drop the wrapper. An already-closed channel or an empty collection costs nothing and removes the shape entirely.
A file may only have one coverage mapping
Section titled “A file may only have one coverage mapping”#[cfg(test)] mod tests inside a file that an integration test also reaches
compiles that file twice: once with cfg(test) for the unit-test binary, once
without for the integration-test binary. The two coverage mappings do not
describe the same line set, and merging them reports missed lines that no
annotated report can point at — --summary-only says two, --text shows none,
and nothing you change moves the number.
The symptom is unmistakable once seen: the count is non-zero and the annotated report is empty. When they disagree like that, do not go looking for the line. Look for a second binary reaching the same file.
The fix is to pick one. Where the file is an adapter, the integration test is the right home anyway — driving it through its port tests behaviour rather than internals, and a test that reaches past the port cannot tell a wrong mapping from wrong behaviour. See engine-api.md.
Neither is a style preference. Per-item coverage exclusion needs
#[coverage(off)], which is nightly-only, so an uncoverable line is a gate
failure with no escape hatch.
When the coverage report contradicts itself
Section titled “When the coverage report contradicts itself”It will, and the cause is almost always stale profile data. cargo llvm-cov clean --workspace does not always clear it, and a report built from a mix of old
and new profiles names symbols that no longer exist in the source.
The symptom is a summary claiming N missed lines while --text shows a clean
file, or mangled names for closures you just deleted. The fix:
cargo clean && cargo llvm-cov --workspace --ignore-filename-regex '(main\.rs)' --fail-under-lines 100To ask the same profile more than one question without re-running the tests — which is what makes the answers disagree — run once and query it repeatedly:
cargo llvm-cov --no-report --workspacecargo llvm-cov report --summary-onlycargo llvm-cov report --lcov --output-path /tmp/cov.infoEvery code is declared in one registry
Section titled “Every code is declared in one registry”codes! { /// The `PROC` codes. proc { /// The program lemonfiber drives is not installed. MISSING_PROGRAM = "PROC-1" => Preflight, }}crates/lemonfiber-error/src/codes/ declares every code, one module per family, in
two files — operating.rs for lemonfiber and the stack it runs, serving.rs for
what the stack does for the household — with what a run that ends on a code leaves
with where that is not a general failure. codes.rs re-exports the families, so a
code is always codes::<family>::<NAME>.
The code that raises a problem names it through that module, and a module that
names one often enough brings it in with use. Code is a newtype rather than an
enum so it can cross every crate as one &'static str, and only the registry can
make one: Code::new exists only in a build with the testing feature, which the
crates take as a development dependency, for tests that need a code no problem
carries.
Codes are never recycled. An operator who searches for one should find the same
answer a year later, so a published code keeps its spelling even where two prefixes
name one domain, as WIRE and WIRING do.
The registry holds only codes something raises. An architecture test,
every_declared_code_is_raised, fails when a declared code is named nowhere in the
code a release is built from. A code nothing raises leaves the registry, and its number
goes into codes::RETIRED, which no declaration may take again.
reference/error-codes.md is rendered from codes::every(), and a test compares the
committed bytes with a fresh rendering; just codes rewrites it. The binary’s exit
code for a problem is read from the same registry, through codes::leaves.
Severity is Ord
Section titled “Severity is Ord”Critical > Error > Warning > Advisory, so a health summary can take a maximum
without a comparison table. The declaration order in the enum is therefore
load-bearing: variants are declared low to high.
Cause, not a chain of symptoms
Section titled “Cause, not a chain of symptoms”cause: Option<Box<Problem>> attributes several failures to one root. A full
disk producing eleven failures is one problem with eleven symptoms, and reporting
it as eleven is how an operator is led to fix the wrong thing repeatedly.
Where a typed error becomes a problem
Section titled “Where a typed error becomes a problem”At the point where it is handled, not where it is raised. The same
Failure::NotFound is a blocking error during setup and an absent field in a
version report — only the caller knows which.
That is why Diagnose is a separate trait rather than a From impl: a ? that
silently converted would decide the severity at the wrong place.
The core never formats
Section titled “The core never formats”Problem has no Display. Rendering is the surface’s job — the core cannot
print, and a Display impl would be a rendering that all three surfaces would
quietly start depending on.
Code does implement Display, because a code is one token with one correct
form and no layout decision to make.
Related
Section titled “Related”- dispatch.md — how a problem reaches a surface
- ports-and-adapters.md — where typed errors come from
This page lives in another repository Rendered from lemonfiber/lemonfiber at 3d26c96, 2026-10-27. Read the source of this page