Status: Superseded by ADR-296 — its D1-D3 are D38-D40 there
Date: September 20, 2026
Target Version: Nikaia 0.0.103
Related: ADR-023 D1 (throws is a set), ADR-158
(std names its error type — the shape this copies),
ADR-159 D1 (a member a ledger describes),
ADR-160 D1 (two or more named members are a generated sum),
ADR-082 D1 (a pub rule is a ledger entry),
ADR-101 (what waits on the last "?" going),
Part III C.1
A public grammar rule’s ledger entry was written throws = ["?"] —
ADR-024 D1’s absence of a claim — because a parse fails with a
rendered string:
Config::parse_file().parse_next(&mut stream).map_err(|error| error.render(source))
A string is not a type, so there was nothing to name. It was the last "?"
written anywhere in this tree: std.contracts has nine throws lines and not
one of them is ["?"], and every other producer of that spelling is an
absence — a call this compiler could not resolve — rather than a decision to
leave something unnamed.
What it cost is a channel. ADR-160 D1 makes a set of two or
more named members a generated sum a program can match on. A set with a
"?" in it is the boxed channel instead, so a program that opens a file and
parses it could not tell the file was not there from the file was not the
shape the grammar says.
ParseError, and a rule’s entry says so.throws = ["ParseError"] on every pub rule’s ledger entry. The type is
nikaia-std’s, in the prelude, keyed in std.contracts with no module in
front — which is Overtaken’s shape (ADR-039’s lock error): a
program never writes a path to it, because it arrives in a catch.
The lowering is one call wider: ParseError::of(error.render(_source)). The
text is unchanged, so a program that prints the error prints exactly what it
printed before; what changes is the channel it travels in.
The backend’s own error has an offset, a list of expectations, what was found and the rule stack — and what it renders out of them is a whole diagnostic: the headline, the line with a caret under it, and what else was possible there. That is the thing a program already prints.
Fields a program could read — the line, the column, the expectation — are a
second question, and nobody has asked it: a handler today prints the message or
falls back to a default. Adding them now would mean fields on a std type in
the ledger, which nothing there has; io::IoError is an enum taken apart by its
variants, which is the shape that exists. The smaller answer is the one that
can be taken back.
throws graph does not hold still has a set.The fixpoint that grows each function’s throws builds its graph from
Item::Fn. A grammar’s entry rule is not one: its contract is written
straight into the ledger (ADR-082 D1). So the lookup found
nothing, and what a parse threw reached no caller at all.
A callee with no node in the graph is read from the ledger instead. That is one line and it is the whole of this decision, but it is not bookkeeping — see §3.
D3 was a miscompilation, and naming the error is what made it visible. While
a rule threw "?", a caller’s set came out empty and the ledger wrote ["?"]
for it — the boxed channel, and a box takes a String. The moment the caller’s
set had a named member from somewhere else, the channel was inferred without
the parse’s member in it:
async fn both(path: &str) -> Result<i64, io::IoError> {
let data = fs::read_to_string(path).await?;
let n = { … .map_err(|error| error.render(_source)) }?; // a String
Ok(n)
}
io::IoError has no From<String>, so rustc refused the generated file —
the one thing Part III C.1 says may not happen. No
program in the corpus had that shape, which is why nothing was red.
And what it buys is the sum. The same function is
Result<i64, __NikaiaThrows_ParseError__io_IoError> now, and a program can tell
the two apart.
"?"s, which are not this. Every one left in a corpus ledger is
an absence — a method the type checker could not resolve, a call into
another file that a single-file build cannot see — and naming those is
resolving them, not deciding anything.rustc refuses the generated file with
E0597. It is the tail wrapper’s shape and not this record’s.All of D1, D2 and D3.
nikaia_std::grammar::ParseError, in the prelude, with Display, Error and
Full; [type."ParseError"] in std.contracts.["ParseError"]; the lowering writes
ParseError::of(error.render(_source)).crates/nikaia/tests/parse_error.rs is the entry’s set, the caller’s set, the
two-member sum, and rustc on the generated file — which is the only
thing that says D3 is fixed, because a ledger assertion would have passed
before it too.examples/inventory’s Stock::file and read go
from ["?"] to ["ParseError"], every example still runs, and no program
without a grammar changes.