Status: Superseded by ADR-280 — its D1-D4 are D9-D12 there
Date: September 20, 2026
Target Version: Nikaia 0.0.73
Related: ADR-023 D1 (the set, inferred), D3 (an error type is
an enum), D4 (the two axes), D6 (an error knows its site), D8 (propagation),
§6 (what Stage 0 did), ADR-013 D3 (the boxed channel this
replaces where it can), ADR-090 (_error), Part I 7.1,
Part III C.1
ADR-023 D1 records throws as a set of error types, derived
over the call graph, and the ledger has written that set for a long time:
throws = ["ConfigError"] is what a program with one error type gets. D3 makes
an error type an enum with payload, and D4 makes the variants within one
type closed, so a catch tells failures apart with the patterns the language
already defines.
Between the two stood the channel. ADR-013 D3 lowers every
throws to Result<T, Box<dyn Error>>, so the set had names and the program
had a box — and D4’s whole first row is unreachable through one. That record’s
own §6 said so, in the row it wrote about itself: a boxed channel gives up
exhaustiveness entirely, which is the opposite of D4.
What that cost is not a missing feature. It is Part I 7.1’s own program:
let config = load() catch {
match error {
ConfigError::NotFound(p) => Config::default()
ConfigError::BadSyntax { line, .. } => { … }
else => throw error
}
}
lowered to Rust in which error is a Box<dyn Error> and the arms are
ConfigError variants. rustc said expected Box<dyn Error>, found
ConfigError about a file the author never wrote, which is
Part III C.1’s class of defect — and the page’s
Status note said Implemented.
A second one rode with it. Box<dyn Error> is Box<dyn Error + 'static>, so an
error carrying a view of the caller’s buffer — ConfigError::NotFound(path),
the page’s own line — was borrowed data escapes outside of function
(E0521). Part I 7.1 writes an error type that borrows and ADR-023
D10 says it should: a path that came from a buffer travels in the error as a
view.
Result<T, Thrown[E]> rather than Result<T, Box<dyn Error>>, where E is the
one member of the ledger’s throws set, and Thrown[E] is the envelope D6
needs: the author’s value plus the site it was raised at.
Everything else keeps the box, and each for its own reason:
"?" in the set is something this compiler cannot name, so there is no
name to write;impl that answered with another type would not satisfy
it.The error type carries a lifetime exactly when it carries a view, and the
position decides how it is spelled — ADR-008 D9’s derivation, one
position over: a function with nothing to borrow from can only write 'static.
Part I 7.1 gives the handler error, and what the page does with it is match on
its variants and print it. So the envelope is split at the binding: the
error into error, and everything the language put around it — the site, and
the trace if this process captured one — into a local beside it.
match error { ConfigError::… } is then the plain match the source wrote,
f"{error}" is the message the author wrote, and error.full() is assembled
from the two halves, which is the lowering’s business and not the page’s.
Split and not discarded, which the first shape of this got wrong: keeping
only the site dropped the trace, so error.full() was silently two lines where
ADR-036 wants three. A test that runs the program is what
said so.
A handler that never reads the error binds _error (ADR-090) and
there is nothing to open.
throw error passes the error on with the site it was raised at.Not the site it was caught at. D6 wants where it was raised, and a handler that re-raised through the ordinary path would make itself the origin of an error it only passed through.
D1’s set is of types and D4 of ADR-023 puts the variants on the
other axis. A variant with named fields is written as a struct literal —
ConfigError::BadSyntax { line, expected } — and the derivation recorded
ConfigError::BadSyntax for it where the tuple form one line up recorded
ConfigError. One error type, two entries, and a set of two is a set nothing
can be named after.
Part I 7.1’s example is a program that runs, and the test that says so runs it rather than reading the Rust: a lowering that looks right and does not compile is exactly the defect.
An error may carry a view of the caller’s buffer, which is what D10 already
promised and E0521 made impossible. In a boxed channel it still cannot, and
that is honest: a box is 'static and always was.
The ledger is unchanged. Nothing new is derived and no column is added — D1’s set was already there and already right, but for D4’s variant. What changed is what the emitter does with it, which is what ADR-023 §6’s error type row was waiting for.
std is untouched. Every std entry writes throws = ["?"], so every
function that reaches std keeps the box — which is every failing function in
examples/ and benches/. That is why this change is safe to make: the corpus
does not go through the new path at all, and the programs that do are the
specification’s.
std throws. Until std’s ledger names its error types, no program
that reads a file has a set this record can name — which is what keeps the
corpus on the box. Naming them is a decision about a published surface and
belongs in a record of its own.NK2401, ADR-101), which needs
the set to be diffable across builds and therefore needs std’s names first.nikaia_std::error::Thrown[E] and throwing, in
crates/nikaia-std/src/error.rs; the channel decision in
crates/nikaia/src/emit/mod.rs (named_error, error_channel), written in
a function’s signature, in a method’s, and in the fn main the runtime gets.Site, and error.full() lowers to Site::full_of.throw error in such a handler is Site::refill — the original site
and the original trace, because a handler that passed an error on is not
where it was raised.contracts::throws::error_type takes the type off a struct-literal
variant.crates/nikaia/tests/error_types.rs, seven of which compile
and run the program.std’s error names, NK2401.