catch Is Named Once, and the Ledger Commit Is the AcknowledgementStatus: Superseded by ADR-308 — its D1-D4 are D11-D14 there
Date: September 15, 2026
Target Version: Nikaia 0.0.8
Related: ADR-023 D1 (throws is a set, inferred into the
ledger), ADR-005 D3 and Part III 13.5
(the three rules for a contract that changes), ADR-025 D1
(every failure is handled or declared), ADR-100 (whose ledger a
consumer reads)
Every failure in a Nikaia program is either caught or declared: a call that
can fail inside a function that does not say throws is refused
(ADR-025 D1). That is the whole of what the language asks at
the signature, and deliberately no more — a type list there is the mistake
checked exceptions are remembered for.
A catch { … } handles everything that reaches it, and throws names no
types, so the set arriving at a handler is open: it grows whenever a callee
gains a failure. The handler is then still a correct program, and still
handles the new error — as it handles everything. What is missing is not a
refusal. It is that nobody was told.
throws set gains a member, every catch over that callee is named in the build output.The ledger records throws as a set (ADR-023 D1). A build that
finds the set grown names each catch site that receives the new error —
“main.nika:12 receives io::DiskFull from read_port now, which it did
not when it was written; it handles it as it handles everything” — and
carries on. This is Part III 13.5’s second rule,
changed and every caller still valid: updated automatically, noted in the
build output, applied to a handler.
--locked the grown set is a failure until the ledger is regenerated and committed.The commit of the ledger diff is the acknowledgement. It carries the error’s
name and the catch sites it reaches, a reviewer sees both, and after it the
new set is the baseline: the note is given once. No marker in the source,
no annotation, nothing to type at the handler.
A catch that reads error, one that ignores it, and one that dispatches
with match error { … } all receive the same note, because the question is
the same for all three: is this new error right where it landed? What the
author does about it — a new arm, or leaving it where it is — is theirs, and
both answers are legitimate.
catch is unchanged.value catch { … } is the one form, and it handles everything that reaches
it. A pattern in front of the handler is not needed for this guarantee and is
not added for it.
NK2401’s reserved case — a catch that no longer covers every error that
can reach it — is this record, widened from the match form to every
handler. The narration is the ledger’s, so a library’s ledger shipped to a
consumer (ADR-100) carries the grown set across the package
boundary and the consumer’s build names its own handlers.
The cost is a line of build output per affected handler, once per change,
and a --locked failure in CI that a committed ledger clears. A program is
never refused for it.
catch takes a pattern. Not needed here, and not asked.throws lowers to a boxed error today
(ADR-013 D3); the set this record diffs exists only once error
types are lowered as enums, which is what ADR-023 D1 already
waits on.All of it, and the thing that said it could not be is what changed underneath.
§5 used to read nothing; std.contracts writes throws = ["?"] on every
entry, so there is no set to diff. That stopped being true when
ADR-280 gave std its error type. Measured: std.contracts has
nine throws lines now — seven io::IoError and two Overtaken — and
not one of them is ["?"]. A program’s own ledger records named sets too
(["?", "io::IoError"] in examples/inventory, whose ? is a grammar entry
and not std’s). A set that gains a member is a change a diff can see, which is
all D1 ever needed.
The first count of this said “exactly one ["?"]”, and it was wrong: the line
it counted is in the file’s own header comment, which explains what entries
wrote before ADR-280. Kept here because the corrected number is the stronger
claim and because a measurement that counted a comment is worth one sentence of
warning.
D1 is NK2402, a warning, given at the call inside the handler rather
than at the catch, because that is what the sentence is about: this call
brings something new in here. The build compares the committed
nikaia.contracts against the one it inferred, once per package, and hands
each function’s newly gained errors to the checker — which already knows, at
every failing call, whether it is inside a guard and which callee it resolved
to. Nothing is walked twice for it.
D2 held before this was written. --locked compares the ledger byte for
byte (D4 of ADR-100), and a throws set that gained a member is
a changed line, so the build already failed there and changed_lines already
narrated which entry moved. What D2 adds is the reason it is the right failure,
which is now written down.
Three things answer nothing gained, and each is right rather than cheap. A build with no committed ledger has nothing to compare, and a first build is not a change. A ledger that does not parse is not an answer. And a function the committed ledger does not name is new — a new function’s whole set is not a set that grew, and nothing was ever written against it.
§4’s other wait — the set this record diffs exists only once error types are lowered as enums — was about the set being meaningful, and it is: what the ledger records are names a reader recognises, whatever the lowering does with them below.