ADR-101: An Error That Newly Reaches a catch Is Named Once, and the Ledger Commit Is the Acknowledgement

Status: 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)

1. Context

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.

2. Decisions

D1 — When a callee’s 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.

D2 — Under --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.

D3 — A handler that matches and one that does not are treated alike.

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.

D4 — The syntax of 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.

3. Consequences

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.

4. What this does not decide

5. What is built

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.

This site is open source. Improve this page.