ADR-104: A Foreign Crate Is Described Before It Is Called, and nikaia describe Writes the Draft

Status: Superseded by ADR-290 — its D1-D5 are D1-D5 there Date: September 15, 2026 Target Version: Nikaia 0.0.8 Related: ADR-020 D5 (entries a compiler cannot infer are written and reviewed like code — this is that rule for every crate), ADR-028 D5 (an entry exists because a program asked for it), ADR-100 D3 (a ledger believed while its hash holds), ADR-038 D7 and ADR-045 D3 (what a foreign call may be handed), ADR-001 D1 (stable toolchain only), Part III 15.2 (the type mapping the draft applies)

1. Context

A call into a Rust crate is a call no ledger describes: the compiler reads no Rust, so it says nothing about the callee, hands the program to rustc, and whatever rustc refuses arrives in Rust’s words about a file nobody wrote. That is the one place Part III C.1’s promise — an untranslated backend error is a compiler bug — could not be kept, because nothing was known to translate against. It is also where every analysis loses its footing at once: sync, throws, keeps, touches, the crossing verdict, all fail closed at the boundary, which is safe and blind.

std’s Rust half already has the answer: its entries are written by hand and reviewed like code (ADR-020 D5). What a foreign crate lacks is somebody to write the first draft.

2. Decisions

D1 — A call into a crate no ledger describes is refused, and the message names the command.

There is no silent third answer for a foreign call. A program that calls regex::Regex::new with no entry for it is refused with regex is not described; run nikaia describe regex. What every analysis then reads at the boundary is an entry, never an absence.

D2 — nikaia describe <crate> writes a draft ledger for the functions the program uses.

The command finds the crate’s calls in the program’s sources, reads the crate’s pub signatures, and writes an entry for each called function and for the types those signatures name — not the whole surface, because an entry exists because a program asked for it (ADR-028 D5), and a review is proportional to use rather than to the crate. A later call to an undescribed function meets D1 and the same command.

D3 — The draft is translated by Part III 15.2’s table, and what the signature does not say fails closed.

Rust the entry
&T a view; T by value is kept
&mut T changed in place (mut)
Result<T, E> throws; the error type where 15.2 can name it
async fn may pause; a plain fn is sync
Option<T> T?
Send on a type may cross a thread
touches, locks everything, may — the signature cannot say

D4 — rustdoc-JSON is read where the toolchain offers it, and the crate’s sources are parsed where it does not.

Both produce the same draft; the file’s header records which. rustdoc-JSON is the better source and is not stable yet; a stable-only toolchain (ADR-001 D1) is not given up for it, so the source parser is what runs today and the JSON reader is what runs the day it is stable. Whatever neither can read — a macro-generated function, a signature over a trait object — is written as ? and left to D5.

D5 — The draft is committed and reviewed like code, and a ? in it is a person’s to fill.

contracts/<crate>.contracts, beside the program’s own ledger, with the crate’s name, version and source hash in the header. It is believed while the hash holds and demanded again after a version change, with the diff shown (ADR-100 D3’s rule). A hand edit is expected: a Rust signature that lies — unsafe impl Send on a type that is not — is caught by the reviewer or by nobody, and the file is where the correction lives.

3. Consequences

Every analysis reaches to the boundary and reads an entry there. The crossing verdict, keeps, sync and throws stop failing closed on foreign calls that a signature answers plainly, and C.1 becomes keepable at the one place it was not.

What it costs: a review per crate, proportional to what the program calls; a file per crate under version control; and the four programs in examples/foreign-runtime/, which call hyper_shim undescribed today and become either described or the reproduction of D1’s refusal.

What it does not change: what a foreign call may be handed. A lock and a Shared still do not cross into foreign code (ADR-045 D3, ADR-061 D1); the entry says what the callee takes, not that the rule bends.

4. What this does not decide

5. What is built

Step 1, D1’s refusal. A call — or a written type — reaching into a Rust crate the manifest declares and no ledger describes is NK2504, and the message is the command. crates/nikaia/src/foreign.rs is the walk; the two sets it takes come from the build rather than from a unit, which is why it is a pass of its own beside the type check and not a rule inside it.

Three things about its shape are the decision rather than the implementation. Only where the manifest declared it with type = "rust": a qualified name this compiler cannot account for — another package, a std path, a typo — keeps whatever message it already had, because refusing on a name nobody declared is Part III C.4’s correct program refused. Once per crate, because the reader’s next move is one command for the whole crate, with the caret on the first call. And hyper-shim in the manifest is hyper_shim in a program, which is the crate name Cargo makes of the key — the translation is the manifest’s, so every reader sees the name a call writes.

Step 5 by hand, which is what D5 expects. examples/foreign-runtime/{crossing,serve,smuggled}/contracts/hyper_shim.contracts is the file nikaia describe will write, produced the way a reviewer would check it: from the crate’s pub signatures, translated by D3’s table, with no touches and no locks on any entry — absent touches reads as touches everything (ADR-033) and absent locks is that column’s third answer, and neither is something a Rust signature can tell anybody.

And the line that was the honest gap is closed. LocalHandle holds an Rc<String> and is not Send — which follows from its fields, and a reader of signatures does not have them. That is why the description used to carry no crosses at all, and why D3’s table cannot answer it: Send on a type means it may cross, and the absence of the word is not the claim that it may not. ADR-123 D2 put the answer where it comes from, and the describer reads it: a pub struct’s fields decide the column, false for an Rc or a raw pointer, true where every field is plainly sendable, and nothing where it cannot tell. LocalHandle’s crosses = false is the command’s now.

And D1’s first sentence is built, which is what the file the refusal asks for was for: a description’s entries are read by the analyses. The signature types the call and what it hands back, throws makes it a place that can fail, and sync makes it one a sync function may not make. A described crate is a package by the spelling rule and not one of std’s modules, so nothing about it is imported — without that the merge turned hyper_shim::serve_once into a demand for use std::hyper_shim.

This was the last thing anybody would have looked for. The file was read, it was parsed to see whether it parsed, and the parsed ledger was dropped: the only effect a reviewed description had was silencing the refusal that had asked for it, while that refusal’s own message promised the reader four answers in return. crosses is the fourth and is not answered here, because a described call is not asked the crossing question at all (D7’s wording) — docs/open-decisions.md carries that one.

Step 2 is built, for a path dependency. nikaia describe <crate> finds the crate’s declaration in the manifest, reads every .rs under its src/, takes the pub fn and pub struct items out of the text, translates them by D3’s table, and writes contracts/<crate>.contracts under a header that says what the file is and that it is to be reviewed — which is the one line Ledger::render’s own header could not say, since that one says do not edit by hand.

It is a signature scraper and not a Rust parser, and the difference is the scope rather than a gap. An item a macro generates is not in the text and is not found; a pub item inside a mod block is read as the crate’s own, because the module path a caller writes is a thing only a real parser knows; and a type it cannot account for is written ?. Each of those is D5’s case, and the command names the ones it could not answer rather than leaving a draft that looks complete.

A path dependency only. A version dependency’s sources are in Cargo’s registry cache, under a layout ADR-002 D1 hands to Cargo and never resolves itself — a describer that guessed at that cache’s shape would be resolving a version. The command says so and names D5’s hand-written file as what to do instead.

And the measurement is the experiment’s own file. §5 above called examples/foreign-runtime/*/contracts/hyper_shim.contracts the file nikaia describe will write, produced the way a reviewer would check it. crates/nikaia/tests/describing.rs runs the command against the crate in the tree and asserts the draft against that file, entry for entry and hash for hash. Two things in the reviewed file are not in the draft and both are D5: crosses = false on LocalHandle, which is read from a field, and the comments, which are a reviewer’s.

Step 3 is built: the hash rule holds. A description is believed while the files it was derived from hash as recorded, and NK2505 says so where one does not — once per crate, with the caret on the first call into it and the command in the message.

The one difference from a ledger’s rule is what happens next. ADR-100 D3’s second row derives the ledger again; a description cannot be, because what it says is a reviewer’s judgement — a crosses read off a field, a signature that lies corrected. So the row becomes a refusal and the command, which is the same thing said to a person instead of to a build.

And only where there is a hash to disagree with. A crate declared by version has its sources in Cargo’s registry cache, and a description that recorded no [sources] recorded nothing; both answer nothing moved. Refusing on an absence is what ADR-169 D1 keeps NK2201 from doing, and here it would refuse every crate that comes from a registry — Part III C.4’s correct program refused.

The order of the rest:

  1. the rustdoc-JSON reader behind a check for the toolchain’s support;
  2. a way to read a version dependency’s sources, which the record leaves to the reviewer and which is what makes the hash rule reach every crate rather than the ones with a path.

#100 carries it.

This site is open source. Improve this page.