ADR-100: A Consumer Reads a Dependency’s Ledger, and Derives It Again Only Where Its Sources Changed

Status: Accepted Date: September 15, 2026 Related: ADR-020 D5 (std ships its ledger and the compiler believes it — this is that rule for every package), ADR-005 D3 and D8 (a contract is derived truth in a committed file, and the file is a pure function of the sources), ADR-021 (the source hashes and the per-unit cache this rests on), ADR-286 D19 and D21 (one crate per package, and a transitive package is invisible), ADR-286 D1 (a package is one namespace), ADR-288 and ADR-010 D1 (why an unresolved answer must fail closed), ADR-251 D1 (the file the hashes live in)

1. Context

Every column of the ledger is a permission a caller acts on: sync puts a body inside a lock, throws decides whether a catch covers everything, touches lets two overlap branches run together, locks lets a call stand inside a door. A package is its own crate (ADR-286 D19), so what a consumer believes about a dependency’s function has to be what the dependency’s own crate was built from — http’s crate emits ok as a plain fn or as an async fn, and the consumer writes an .await or does not. Two builds can only agree if one of them stops deriving, because the consumer cannot see what the dependency saw: a transitive package is not in the consumer’s ledger, deliberately (ADR-286 D21).

The ledger exists for exactly this — derived truth, computed once by the party that has the inputs, committed, and read by everyone else (ADR-005 D3). std already works this way: std.contracts is baked into the compiler and believed, and its inferable half is regenerated by a test that fails on drift (ADR-020 D5).

What has to be decided is the cost. Deriving a dependency again on every build is the inference of every unit of every dependency, on a program that may have a hundred of them and changes one. Believing blindly is a stale ledger read as permission, which is the polarity ADR-010 D1 forbids: a stale touches is a data race with no message, a stale locks is a lock inside a lock. The two are reconciled by the one fact the build already computes — the hash of every source file (ADR-021).

2. Decisions

D1 — A consumer reads a dependency’s nikaia.contracts and never derives a dependency’s contracts itself.

The rule std has, for every package: a package’s ledger is written by the package’s own build and read by every consumer. One answer, one author, and the divergence of two builds is impossible by construction rather than avoided by luck.

A believed ledger hands over the package’s own entries. A library’s ledger file is its build’s record, so it carries its own dependencies’ entries under their names; the consumer drops them, so the believed answer is the same answer the derived one would be rather than a larger one (ADR-286 D21).

D2 — The inference graph is the package, not the file.

A package is one namespace (ADR-286 D1), so its ledger is inferred over all of its units at once: a call from main.nika to a function in helper.nika resolves, and sync, throws, touches, locks and sharing are answered across the two. A call that leaves the package is answered from the callee’s ledger (D1), and a call into a foreign crate from the entry nikaia describe wrote for it (ADR-290); an entry that says ? at a position fails closed there, as before (ADR-288).

D3 — The ledger carries the hash of every source it was derived from, and a consumer believes it while the hashes match.

Changed by ADR-251 D1: the hashes live in nikaia.derived, the record beside nikaia.contracts, not in its header; the rule is unchanged.

nikaia.derived records, per unit, the SHA-256 of the file the entries were derived from — the same hash nikaia.lock already holds for the cache key (ADR-021). At a consumer’s build there are three cases and no fourth:

the dependency what happens cost
has a ledger, and its sources hash as recorded the ledger is believed; nothing is inferred one hash per file, already computed for the cache
has a ledger, and a source hashes differently that package’s ledger is derived again from its sources, written, and the diff is narrated (NK2401) the inference of a package that has to be recompiled anyway
has a ledger and no sources — a published package the ledger is believed nothing

A path dependency with sources and no ledger takes the second row: its ledger is written by whichever build first needs it, and committed with it.

A dependency is never believed against its own sources. The second row is what makes the first one safe: a stale ledger is a hash that does not match, and a hash that does not match is a derivation, never a belief. That is the whole of why the check is cheap — it is not a comparison of contracts, it is a comparison of hashes, and the expensive part runs only where the sources moved.

A ledger that will not parse, or has no recorded sources, is not believed. Both are derivations rather than errors: it is a generated file, and nothing was recorded must not read as nothing changed (ADR-010 D1).

D4 — --locked is where contracts are compared byte for byte, and a development build never does it.

nikaia build --locked regenerates every ledger the build has sources for — the program’s and each path dependency’s — and compares each against its committed file, failing on any difference with the narrated diff (Part III 13.5). That is the check that costs an inference per package, and it belongs in CI, once per change, not in every build on a developer’s machine. A development build does D3 and nothing more.

D5 — The build is ordered by the dependency graph, and a ledger exists before its consumer is checked.

A package’s ledger is a build input of every package that depends on it, so dependencies are processed first — the order Cargo already imposes on the crates (ADR-286 D19). Nothing about the graph changes: a transitive package stays invisible to the consumer’s names and to its ledger (ADR-286 D21); what the consumer reads is its direct dependency’s answer, which that dependency computed with its own dependencies in view.

D6 — A boundary mismatch the language below reports is translated as a stale ledger.

If rustc reports, at a call across a package boundary, that a value is not a future or that a Result was not expected, the driver reports it as the ledger of <package> does not match its sources and names the package to rebuild — Part III C.1’s translation for the one way D3 can be bypassed, a hash edited by hand. It is a safety net, not the mechanism: consistency is established by D3, and this only names the case where somebody defeated it.

3. Consequences

NK1129 fires only on bodies that pause. It reads, inside a package, the package-wide answer (D2) and, across one, the callee’s ledger (D1), so a call that leaves the unit is not read as cannot be vouched for. The same holds for throws, touches, locks and sharing.

What a large program pays. A build with a hundred unchanged dependencies hashes their sources — which the cache key needs anyway — and infers none of them. Editing one path dependency re-derives that one. The byte-for-byte comparison runs in CI under --locked.

What a library author owes. Commit nikaia.contracts, as Part III 13.5 already says. A ledger whose hashes no longer match is regenerated by the next build and shows up as a diff in review, which is the ledger’s purpose.

--locked compares a ledger per path dependency. That is the one cost D1 adds to CI, and it is the same check the program’s own ledger gets.

4. What this does not decide

This site is open source. Improve this page.