ADR-321: Build-Time Code Is Compiled and Run, Not Interpreted — Against the Package’s Dependencies, Linked Dynamically, One Crate per Package, With a Step Budget

Status: Accepted Date: October 5, 2026 (D13-D15: October 6, 2026, #479; D16-D19: October 9, 2026, #525) Answers: what runs a comptime initialiser’s calls, and what stops one that does not end Related: ADR-100 D1, D3 (a consumer believes a dependency’s ledger while its sources hash as recorded); ADR-290 D5 (a Rust crate’s description is the consumer’s, reviewed like code, and a judgement rather than a derivation); ADR-287 D13-D18, D21 (what a build-time body may do; restrict and interpret, no step budget, the call depth; an integer comptime is open, so no type waits for a value); ADR-177 D1-D3 (a grammar runs while the program is built by compiling the parser it generates, because an interpreter is a second implementation); ADR-311 (what a result has to be to cross, and how it is written); ADR-318 D5, D6 (an opaque std type crosses through its constant column; which std functions may run at build time); ADR-285 D5, D8, D22 (an overflow aborts; the check is carried by the generated project; a literal expression is folded by the checker); ADR-005 D8, ADR-021 (determinism and the build cache); ADR-310 (what a build may read)

1. Question

What executes the code a comptime calls, and what bounds it?

A comptime initialiser that calls a function runs that function while the program is built. Until now an interpreter inside the compiler did it, and nothing stopped a body that did not end.

An interpreter is a second implementation of the language. It computed sq(100000) with fn sq(x: i32) -> i32 as 10000000000, where the program stops on the overflow; it cannot run a std function written in Rust, such as 5.seconds(); and it is slow enough that fib(30) takes the build minutes. A build without a step budget hangs without a word, and a budget added later refuses only programs that already hang is not so: it also refuses slow ones that end.

2. How others do it

Language Build-time code runs Bounded by
Rust const rustc’s own interpreter (Miri’s core); no heap values a lint after a large number of steps, an error by default
Rust build.rs, proc-macros compiled for the host and run, with the dependencies nothing; full privileges
Zig comptime the compiler’s interpreter backward branches, 1000 by default, raised with @setEvalBranchQuota
C++ constexpr the compiler’s interpreter steps (-fconstexpr-steps), loop iterations, operations
Nikaia grammars (ADR-177) the generated parser, compiled and run —

What transfers: Rust’s build scripts show that compiling for the host against the dependencies works at scale; Zig and C++ show that a deterministic count is how a build-time evaluator is bounded. What does not: their interpreters, for ADR-177 D1’s reason.

3. Options, weighed

Option Why it lost
Keep the interpreter and teach it the language’s types a second implementation that every feature must be built into twice and kept equal
Interpret small cases, compile large ones two implementations, and a size threshold that decides which answer a program gets
Emit Rust const fn and let rustc’s const evaluation compute it no heap: a table built from a file, the main use, cannot be a value there
Link the build-time program statically measured: about 1 050 million instructions to compile and link a call into std, nearly all of it the linker merging std’s dependencies and their debug information; 338 million with the debug information stripped
Load the build-time code into the compiler’s own process, using the std the compiler contains the compiler holds only the part of std it calls, its symbols are not exported, and it is built with its own profile; a std shipped as a dynamic library with the compiler ties it to one set of build options and one platform’s rules for shared libraries
A persistent host process that loads std once saves only the start of one program per package and build (about 36 million instructions measured); not worth its machinery yet
A time limit not deterministic: the same build would pass on one machine and fail on a slower one (ADR-005 D8)

4. Decision

D1 — A comptime whose initialiser calls a function is compiled to Rust and run; it is never interpreted.

The calls are lowered as the program is, built for the machine that builds and run there; the program’s answer is the value. One implementation of the language computes both the build-time and the run-time answer, so an overflow, a narrowing or a std function behaves at build time exactly as it does when the program runs: comptime C = sq(100000) stops the build at the multiplication, in the program’s words.

An initialiser of literals alone — 4 * 1024 — is folded by the checker, which computes it anyway (ADR-285 D22), and is not compiled.

D2 — What a build-time body may call is unchanged, and now includes std’s Rust half and the Nikaia dependencies.

ADR-287 D13-D14 still decide it from the ledger: sync, and a touch set that is empty or the build’s parameters. A function that passes may run whether this program, std or a Nikaia dependency declares it (D8): 5.seconds() runs from std’s Rust half. A function the ledger does not describe touches everything and is refused (NK1152). A Rust crate’s never runs (D9).

Narrowed by D14: the library holds Nikaia code and std only.

Cargo builds the package’s dependencies as it does for the program. Beside the package the compiler generates one crate that names std and every Nikaia dependency build-time code may reach and builds it as a dynamic library, from the same compiled dependencies: a crate type of its own changes nothing the dependencies are built with, so they are reused and not built again. The build-time code links against that one library and reaches the dependencies through it.

Measured on a call into std: about 132 million instructions to compile and link, and 36 million to start and run, against 1 050 million for a static link. The library is rebuilt when a dependency changes, not when a comptime does. The shipped program stays statically linked.

Where the target is not the machine that builds, the dependencies build-time code reaches are built a second time, for that machine, as Cargo builds a build script’s.

D4 — One crate holds every comptime of a package, and its results are cached.

The package’s build-time code is one crate, compiled by rustc directly and run once; Cargo starts only for the dependencies. Its results are kept under a key of the code that produced them — each initialiser and everything it calls — the toolchain and the build’s parameters (ADR-287 D15), so an unchanged comptime is neither compiled nor run again.

D5 — The build-time run goes beside the rest of the build.

No type waits for a build-time value (ADR-287 D21): the run starts once the ledger has decided what may run, and only the check that a value fits and the writing of the values wait for it.

D6 — A result reaches the compiler as data and crosses as ADR-311 says.

The build-time program writes each value in an encoding the compiler reads back, as a grammar run’s does (ADR-177 D4); the crossing to the program is unchanged. A value the crossing cannot write is still refused by what it is (NK1167).

D7 — There is a step budget, and it is counted, not timed: ten billion by default.

The compiled build-time code counts each turn of a loop and each call; past the budget the run stops, and the build is refused with NK1152, naming the comptime and the call path it was in, which the run keeps as a stack of function numbers. The count is the same on every machine, so a build that passes passes everywhere. The call depth stays bounded as well.

The default is 10 000 000 000 steps, chosen generously: a mistake should cost the programmer about twenty seconds, not a hung machine, and compiled code takes on the order of 10⁸ to 10⁹ counted steps a second. It is an estimate, not a measurement, and it is not tuned to this repository’s own comptimes. A call into std’s Rust half is one step however much it does inside; D10 bounds what such a call can allocate.

D10 — The build-time run’s memory is bounded: 4 GiB by default, counted by its allocator.

Changes ADR-287 D17.

A loop that grows a list fills memory faster than a step budget stops it, and a machine that starts swapping is the hang this record is about. The build-time crate is generated, so it gets its own global allocator, which counts the bytes live at each request and stops the run past the bound, refused with NK1152 like D7. It counts what the program asks for, not what the operating system grants, so the same program stops at the same request on every machine of one architecture; and it counts std’s Rust half too, since every allocation passes through it. The default is 4 GiB.

Rejected: a limit set by the operating system (setrlimit), which macOS does not enforce for a process’s memory and Windows does not have in that form.

D11 — A program raises either bound at the comptime that needs it: comptime(steps: 50G, ram: 16Gi) NAME = ….

The bounds are named in parentheses after the word, as a dsl block’s build-time arguments are (ADR-299 D3), with ADR-322’s scales; either may be left out, and comptime NAME = … keeps both defaults (10G steps, 4Gi). A name other than steps or ram is refused (NK1109). The word keeps the name on its left (ADR-287 D20), so a package’s constants keep their form.

Not in the manifest, which would raise it for every comptime of the project because one needs more, and not on the command line, which would make one project build on one machine and not on another. The bound a comptime asks for is visible in review where the cost is.

D12 — A build-time run that takes long says which one it is.

The compiler starts the run, so it knows how long it has been going; after a few seconds it prints which comptime is still running and for how long. It is a message, never a bound, and changes nothing the build produces.

D8 — A Nikaia dependency’s functions run at build time only on entries this build derived from their sources.

A consumer believes a dependency’s shipped ledger while its sources hash as recorded (ADR-100 D3). That guards against a stale ledger, not a false one: a package can ship honest hashes beside an entry that says touches = [] for a function that writes a file. At run time such an entry misleads the checker; at build time it would let the package’s code read and write on the building machine. So before a dependency’s function runs at build time, this build derives that package’s ledger from its sources, keyed by their hashes and done once per version, and D2’s rule reads the derived entries. Where they disagree with the shipped ones, the build is refused (NK1152), naming the package and both answers. A package that ships no sources has nothing to derive from, and none of its functions runs at build time.

D9 — At build time only Nikaia code runs, and std’s Rust half.

What this compiler can vouch for is what it derives: sync and touches of Nikaia code, its own and, by D8, a dependency’s. A Rust body gives it nothing to derive from — Rust reaches the system through unsafe and calls no signature shows — and a description’s columns are a person’s judgement (ADR-290 D5), made for ordering statements at run time. A claim does not become a proof by being written twice. So a Rust crate’s function never runs at build time, described or not, and neither does a Nikaia function whose calls reach one: the call is refused (NK1152), naming the Rust function and the path to it. std’s Rust half is the exception: it is this project’s own code, reviewed and shipped with the compiler (ADR-020 D5).

Rejected: believing the description’s touches, which runs a stranger’s native code on a judgement made for another purpose; and a second, explicit per-function claim in the description, which is the same judgement written again.

D13 — A grammar may call its package’s functions and its Nikaia dependencies while it runs, under D2.

Added October 6, 2026 (#479).

A grammar’s actions are Nikaia (ADR-177 D2), and what they call is what any build-time body may call: a function of the grammar’s own package or of a Nikaia dependency, on entries this build derived (D8), sync and touching nothing but the build’s parameters (D2). A driver’s grammar resolves names, decides which column may be NULL and asks the logic package (ADR-270 D24) in ordinary functions of the driver. A call whose path reaches a Rust crate or a C function is refused as in any comptime (NK1152).

D14 — The build-time program contains no foreign code, not even linked.

D3’s library is built from the Nikaia code of the packages build-time code may reach and from std, and from nothing else: no library an extern "C" block links, no Rust crate but std’s. Loading a library runs its constructors (an ELF .init_array, a Rust crate’s static initialiser) before any function is called, so a library that is only linked would run foreign code past D9. A package that mixes Nikaia with extern "C" or a Rust crate is compiled a second time for the build-time program without those parts; nothing reachable at build time is lost, since D9 refuses every call that reaches them.

Rejected: linking the package as the program does and relying on no call reaching the foreign part, which leaves the load to run it.

D15 — There is no way to let foreign code run while a program is built.

Not an entry in nikaia.toml, which the author of a cloned project writes; not a grant on the user’s machine, which a README can talk a reader into; not a sandbox. What build-time code needs - parsing, checking against a schema, computing tables, asking the logic package - is written in Nikaia, and foreign code is what the program calls when it runs.

Rejected: a grant held by the user and bound to the hash of the foreign code, which still runs that code natively with every right once granted; and running foreign code in a WebAssembly runtime without imports, which would contain it but brings back the runtime, the wasm32 target and the determinism asterisk ADR-287 D18 declined, for no build-time task that needs it.

D16 — A build-time run that stopped is NK1244, in the words the program would print.

Decided in #525.

The run was allowed, it ran, and the code it ran aborted: an overflow, an index past the end, an unwrap of nothing, a panic(). That is the program’s own mistake met early, and the fix is the fix of the program, so it gets a code of its own. NK1152 keeps what the rule refuses before anything runs, and D7 and D10’s bounds, which are limits of the build rather than mistakes of the code; NK1127 keeps what this compiler cannot compute. The message is the run time’s, at the line where it stopped, with the comptime it was computing.

Rejected: keeping NK1152, which sends the reader to the rule a body broke when it broke none; NK1127, which sends them looking for a missing feature.

D17 — The same mistake has the same words at build time and at run time; NK1165 is retired.

Changes ADR-311 D4.

An initialiser of literals the checker folds (comptime Y = XS[7]) stops as the compiled run does: NK1244, with the run time’s message. NK1165’s own sentence goes, and the code is not reused. A better message for an index past the end is written for the run time, and both get it.

Rejected: translating the run’s abort back into NK1165’s sentence, which makes one mistake read two ways depending on when it is met; D1 is that it does not.

D18 — A bound that is not a number the build knows, or not above 0, is NK1102.

comptime(steps: one()) and comptime(steps: -5) give an argument the bound does not take, which is what NK1102 says; each gets its own sentence, so a negative number is not called not a whole number. NK1109 stays for a name other than steps or ram.

D19 — A check with no place to compile build-time code refuses, it does not leave the value open.

A check made through the library without a place to compile and run build-time code, which every command-line build has, cannot compute a comptime that calls a function. It refuses it with NK1127, with a note that this check has no place to compile build-time code in. A caller is never told a program is checked when one of its values was never computed.

Rejected: leaving the value open, as the ledger’s pass did, which is quieter and lets a caller take an unchecked program for a checked one.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.