ADR-280: A Failure Travels as Its Error Type in a One-Word Envelope — the Site Is Free, a Trace Is Asked For, and the Failures That Joined It Are Shown, Not Read

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

1. Question

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:

2. How others do it

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.

3. Options, weighed

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).

4. Decision

The site, the trace and the long form

D1 — An error does not capture a stack trace unless the process asked.

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.

D2 — Every error carries its site; none is promised a trace.

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.

D3 — The absence of a trace or a site is stated, never silent.

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.

D4 — 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.

What std throws

D5 — io::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.

D6 — It lives in 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).

D7 — Its variants are few, because they are closed.

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.

D8 — The payload is what the failure was about, as owned text.

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.

The channel

D9 — Where the error set names exactly one type, the channel is that type.

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.

D10 — A handler is handed the error, with the envelope opened once.

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.

D11 — 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.

D12 — The set names an error type, never a variant; a variant’s type is its path but the last segment.

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.

D13 — A channel that is one library error is its envelope, 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.

D14 — A throw of a library’s error has a site.

throw io::IoError::NotFound(p) is lowered as a program’s own error is.

D15 — A set of two or more named members is one generated 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.

D16 — Each member of a sum carries the channel it would have alone.

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.

D17 — A 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.

D18 — A 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).

The failures that joined

D19 — The joined list is of the channel’s own type, and each entry keeps its envelope.

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.

D20 — An uncaught failure prints the list, indented, and the trace note is said once.

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.

D21 — The joined list is a diagnostic; a program does not read it.

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.

D22 — A handler that wraps the error starts a new failure.

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.

D23 — Every envelope can hold the list, though only two things fill it.

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.

The representation

D24 — The site, the trace and the list are one tagged word.

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.

D25 — The word is never zero, so a Result finds its niche there.

For any E a program writes; asserted in error.rs’s tests.

D26 — The boxed channel’s envelope is three words, and stays two allocations per 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.

D27 — The origin is a reference to its text.

The emitter writes raise(error, &"load") and throwing(error, &"load"); the type is nikaia_std::error::Origin.

The box

D28 — A 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.

D29 — A cleanup’s failure is raised where its settle point is.

Handed on as a Thrown<Failure> raised at the function the settle point stands in (ADR-297), so it has a site.

D30 — Where more than one error type arrives, 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).

5. Consequences

6. What this does not decide

This site is open source. Improve this page.