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)
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).
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).
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).
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).
--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.
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.
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.
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.
open-decisions.md’s first
question; this record says only what happens once it is there.nikaia.lock
records (ADR-021); a different digest is a change to that
record, not to this one.