Status: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: ADR-023 §3.2 (what an automatic stack trace
costs); what the failure channel is once the ledger names the error set
(#239,
#243); what std
throws; where the failures that joined an error live
(#199, twice: the box,
and a caller with a bare channel); open-decisions.md’s does the language
have a type for a list of errors? (the owner: no); the owner’s objection that
every error paying for the special cases is not our philosophy
Related: ADR-023 D1 (the error set, inferred into the
ledger), D3 (an error type is an enum with payload), D4 (variants closed, the
set of types open, the named residue), D6 (an error knows its site), D8
(propagation is unmarked), D10 (the error is pointer-sized; a payload may be a
view) ·
ADR-013 D3 (the boxed channel, Box<dyn Error>) ·
ADR-024 D1 (?, the absence of a claim) ·
ADR-292 D8, D9 (every error has a secondary list; an overlap
fills it) · ADR-292 D19 (a joining block hands every branch one
channel) · ADR-297 D4 (a cleanup’s failure joins the error
already leaving) ·
ADR-313 D3 (a type lives in its module) ·
ADR-291 D19 (NK1151) · ADR-308 (a handler that
ignores the error binds _error) · ADR-308 (a grown set is
named at the handlers) · ADR-289 (an error message is written
for the operator) · ADR-010 and ADR-296 D19 (no
program pays for the worst case; the build switch chooses the executor and
nothing else) · ADR-296 D24 (a grammar’s entry rule) ·
ADR-108 D3 (Outside, a case of io::IoError) ·
Part I 7.1 and 8.1.2, Part III C.1
When a function fails, what value leaves it - what type does the failure travel in, what does it carry besides the author’s error, and what does that cost a function that does not fail?
ADR-023 decides what a program writes: a bare throws, an
inferred set of error types in the ledger, throw, an enum per error type,
and an error that knows where it was raised. It leaves the carrier open, and
the carrier is where every one of those promises is kept or broken:
Box<dyn Error>: ADR-023 D4’s a match over one error type’s variants is
exhaustive cannot be reached through a box, and through one Part I 7.1’s own
teaching program is refused (rustc: expected Box<dyn Error>, found
ConfigError, and E0521 for the error that holds a view);Result that could
carry them;| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
Rust std::backtrace |
Backtrace::capture() is disabled unless RUST_BACKTRACE/RUST_LIB_BACKTRACE is set; force_capture() always captures |
a capture is ~28 300 instructions per error; a flag of one’s own on top of capture() silently captures nothing |
checked against the toolchain |
Rust io::Error |
one word, packed per kind, boxed only for a custom payload | the existence proof for ADR-023 D10 | ADR-023 D10 |
Rust Result niches |
no tag of its own where a half has an impossible value (a reference is never null) | an envelope with no niche makes every Result wider, fail or not |
measured (D24) |
Rust std::io::ErrorKind |
some forty kinds | a closed enum of forty is forty lasting commitments |
D7 |
| Java checked exceptions | type lists written by hand | people wrote throws Exception |
ADR-023 D1 |
Swift swifterror |
the error in a designated register | needs a backend that owns its calling convention | ADR-023 §3.1 |
What does not transfer: a backend that owns its own calling convention, and a garbage collector that would make a heap-allocated envelope free to keep.
| Question | Rejected | Why it lost | Decided in |
|---|---|---|---|
| a stack trace | captured on every error | +28 300 instructions per error, +1507 % on a program that raises 20 000; nothing reads it | D1 |
| a build flag | the error type would depend on how the program was built (ADR-296 D19) | D1 | |
| the long form | {error:full} as a format spec |
a format spec passes through to the language below, which has no full: a special case in the emitter |
D4 |
| the channel | Box<dyn Error> for every throws |
no exhaustiveness, no view in an error ('static), Part I 7.1 refused by rustc |
D9 |
| a channel named after one of two types | a lie | D9, D15 | |
| a set of two | one sum per function | two functions that fail alike would convert at every ? |
D15 |
| a sum per module | a different type per file; a failure cannot cross | D15 | |
| a library’s error | bare, no envelope | a joined list is lost at the first propagating caller, and rustc refuses From<Thrown<IoError>> for IoError |
D13 |
an envelope derived per function, like locks |
a fixpoint, a second rule for sums, a column that can be wrong - and the uniform rule measured free | D13 | |
| strip the envelope at the hop | drops the list the hop was asked to keep | D13 | |
| into the box | .map_err(…) at each ? |
eight emitter sites must know both channels; a ninth forgets | D28 |
| the site written into the message | a string the list and the trace cannot follow | D24 | |
| the joined list | a readable error.secondary with a type (Failures, or dyn as a type) |
no program reads it | D21 |
| two envelope types, with and without a list | a conversion at every boundary between them | D23 | |
| the representation | site, trace and list as three fields | every Result that could fail 96 bytes wide where the bare error is 16, the three fields alone 88, for two special cases and a switch |
D24 |
one allocation per boxed throw (Raised<E>) |
a generic envelope cannot be found again by a downcast | D26 | |
std’s error |
the ["?"] in every entry |
the set added nothing for the one library every program uses | D5 |
one variant per ErrorKind |
a variant cannot be taken back | D7 | |
IoError in the prelude |
an error type is common is the argument ADR-313 D7 refuses | D6 | |
| the payload as a view | there is no tether, and a lifetime would reach every std signature |
D8 |
The weights that decided: no cost for the common case (a trace off, one word for the rest, the special cases paying for themselves), and the compiler working rather than the developer (the program never names a channel or a sum).
NIKAIA_TRACE turns capture on for a process; it is read once and drives
force_capture, so one variable decides whether there is a trace and it is
the one the message names. A runtime switch, not a build one: the same source
produces the same types.
The site - where it was raised - costs nothing at run time: the compiler writes it into the binary as text (ADR-023 D6). Part I 7.1 says so and does not promise a trace.
error.full() on an error raised without tracing ends in (no trace; set
NIKAIA_TRACE=1 to capture one). An error with no site - one from below
Nikaia, or a library’s error that reached the program from a call rather than
a throw - says (raised below Nikaia; no site recorded) rather than
inventing one, and carries what the failure was about in its payload instead.
error.full() is a method, not a format spec.A hole already holds an expression, so f"{error.full()}" needs nothing new.
{error} is the author’s message and nothing else, which keeps
ADR-023 D7’s short form safe in front of a stranger.
std throwsio::IoError is what std throws, and every entry says so.An enum with payload and an impl Error. Every failing std entry names it,
except the lock’s two set(…; after:) doors, which name Overtaken. No
std entry writes ["?"], and a test holds that. Of 120 entries nine
throw: two name Overtaken, and the other seven fail with one type of the
language below.
io, and a program that takes it apart writes it out.use std::io and io::IoError::NotFound(p), by ADR-313 D3. A
program that only passes a failure on writes nothing, because propagation is
what a call does (ADR-023 D8).
NotFound, PermissionDenied, NotText, Outside and Other, each a
String. NotFound and NotText are the specification’s own,
PermissionDenied is the other environmental failure a program acts on
differently, Outside is a name that would leave the fs::Root it was given
(ADR-108 D3: a case, not an error type, so no throws set grows),
and Other is ADR-023 D4’s named residue - everything
std::io::ErrorKind distinguishes and these do not. Adding one is a breaking
change and therefore a record.
std attaches the path at the call that knew it. Owned, not a view: this is
where std knowingly falls short of ADR-023 D10 until the tether
exists.
Result<T, Thrown<E>>, where E is the one member of the ledger’s throws
set - a type this unit declares, or one a ledger describes (std’s, a
dependency’s). The opaque Box<dyn Error> is kept only for a reason about the
set: a "?" in it (nothing to name), or a method implementing a trait (it
answers with the trait’s channel). A set of two or more is D15. The error type
carries a lifetime exactly when it carries a view.
At the catch’s binding the envelope is split: the error into error, the
site, trace and joined list into a local beside it. match error { … } is the
plain match the source wrote, {error} is the author’s message, and
error.full() is built from the two halves and includes the joined failures.
The same holds for a library’s error. A handler that never reads the error
binds _error (ADR-308) and opens nothing.
throw error passes a failure on with the site it was raised at.And with its trace and its joined list. A handler that passes an error on is not where it was raised.
ConfigError::BadSyntax { … } records ConfigError, and
io::IoError::NotFound(p) records io::IoError, in the error-set derivation
and in the rule that tells a variant constructor from a call.
Thrown<E>, everywhere.Every function whose failure is one library error type has Thrown<E>, and a
generated sum’s member for one is Thrown<E>, so a propagating caller has the
channel its callee has and the joined list goes with the failure through any
number of callers. A ? from a std call, which hands the error bare, puts
the envelope on through From<E> for Thrown<E> with no site (D3). A grammar’s
entry rule hands its parse error bare, as std does. Measured
(benches/envelope): the envelope is one word (Result<i64, io::IoError> 32
bytes, with the envelope 40) and costs nothing measurable on either path,
×0.95-×1.03.
throw of a library’s error has a site.throw io::IoError::NotFound(p) is lowered as a program’s own error is.
enum, per set.Not per function, so two functions that fail alike share it and a ? between
them converts nothing. Defined once at the crate root and named crate::…, so
a failure crosses module files. The program never writes its name. A set with
a "?" in it is not a sum - it is the opaque channel.
The envelope Thrown<E>, for a type this unit declares and for a library’s
type alike (D13). The sum adds nothing of its own; its full() asks the member.
match over a sum is taken apart by member.One match per member, with the arms naming that member’s variants and the
source’s catch-all written into each; every member gets an arm. throw error
in a member’s arm puts the failure back in its variant, with its site.
match over a catch’s error with more than one type arriving needs else - NK1151.The set of types is open (ADR-023 D4), so arms over two types’ variants cover no set. Refused by the checker for the caret and by the lowering.
Changed by ADR-357 D1, D2, D5: a match that names every non-fatal type arriving is complete and lowers with no catch-all; else is needed only where the set holds "?" or an open from(p).
Thrown<E> carries a list of Thrown<E>, because a joining block hands every
branch the same channel (ADR-292 D19); each entry keeps its site,
and a secondary with secondaries is the tree ADR-292 describes.
The boxed channel joins through a downcast; an error in the box with no
envelope at all is put in one when something joins it, with no site, so what
joins it is kept.
A main that hands back an Err is printed through Debug, so that is the
long form, each joined failure under the one it joined. The no trace note is
said once, at the top, in the typed envelope and the box alike.
It travels with the failure, is kept by throw error, and is shown by the long
form, an uncaught failure and nikaia explain. There is no error.secondary
and no type for a list of errors.
Combining failures is a catch on the block; the handler has the winner.
throw error hands the whole list on; wrapping it in an error of the
program’s own starts a failure whose site is that throw. ADR-292
D4’s throw LoadFailed(error, error.secondary) is withdrawn.
Only an overlap and a failing cleanup fill it, but a failure raised anywhere
can reach a function that joins, and two envelope types would convert at every
boundary. What it costs where nothing joins is D24’s tag, nothing more.
Odd: the address of the site’s text with the low bit set - nearly every error,
no allocation. Even: the address of a cold box holding the site, the trace and
the list, made where a process traces (at the throw) or where something
joins (at the first join). Thrown<E> is E and this word; Site<E> is the
word; Raised is its box and the word. Measured: Result<Int, Thrown<E>> for
a field-less enum is 16 bytes, what the bare error costs, against 96 with
three fields.
Result finds its niche there.For any E a program writes; asserted in error.rs’s tests.
throw.The envelope and the author’s error behind its own Box<dyn Error>, because
joining and error.full() find the envelope by a downcast that names one
concrete type. On the failure path only.
The emitter writes raise(error, &"load") and throwing(error, &"load"); the
type is nikaia_std::error::Origin.
Thrown<E> put in the box becomes the box’s own envelope, with its site and list.std writes From<Thrown<E>> for Box<dyn Error>, making a Raised with the
same error, site, trace and list. Thrown<E> is therefore not an Error
itself; the emitter writes nothing new.
Handed on as a Thrown<Failure> raised at the function the settle point
stands in (ADR-297), so it has a site.
error is an Error, one of these.The checker gives a handler’s error a type known by its trait: Error’s
methods (message, full) resolve on it, f"{error}" and throw error are
written as for one type, and a match takes it apart by member (D17, D18).
Anything only one member has - a field, a method of its own - is reached
through the match, and anywhere else is refused. A message about it names the
members: this value is an error, one of ConfigError, io::IoError.
Why not an exact sum in the checker’s types. It would let a program do
nothing more with error than the trait and the match already do, and every
place in the compiler that distinguishes kinds of type would have to learn it.
Why not leave it unknown. What is not known is not checked, and the
program’s mistake then reaches the backend (Part III C.1).
Decided October 8, 2026 (#512).
"?"
anywhere, or a trait method: the box, which keeps the site and the list
(D28). examples/sqlite goes through the sum; every program that reads a file
goes through D13.catch matches on variants and is
checked, and an error may carry a view of the caller’s buffer outside the box
(a box is 'static).std’s ledger names io::IoError and Overtaken,
which is what lets ADR-308 diff a set.std’s failure apart writes a second
use (D6); std’s payload is owned text (D8); a boxed throw is two
allocations (D26); two unsafe reads and one free in one type of about eighty
lines, checked under Miri (D24); one rustc lint (non_camel_case_types)
allowed on the sum’s deliberately unusual name (D15).error.secondary.E is, and §3.1’s in-value versus out-of-band status.NK-2C7 resolved offline) and
mapping a captured Rust trace back to .nika lines (ADR-300’s
source map on a second input) - ADR-023’s and ADR-300’s, not this record’s.IoError gains variants - a record each (D7) - and read_both’s
internal pair error, which is in no ledger.ParseError.Failures in std is still the answer and breaks nothing (D21).