ADR-287: A comptime Binding Must Be Evaluated While the Program Is Built — Where an Item or a Statement Stands, by a Body That Is sync and Touches at Most the Build’s Parameters

Status: Accepted Date: October 3, 2026 (D19: October 4, 2026, #380) History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: Part II 10.2’s missing declaration (there is no const in the parser at all) and Part I 9.2’s Constants without a syntax; what should the word say; #232 (a comptime binding at item level has nowhere to stand); ADR-310 Q4, what is a program allowed to do in const?, and with it Q1-Q3 Related: ADR-310 (stays Open for one thing: what bounds a build-time body’s memory, D17); ADR-285 (the shared constant fold, which comptime turns into a demand); ADR-288 D21, D22 (sync means never pauses; touches is inferred over the call graph); ADR-005 D8 and ADR-021 D5 (byte-determinism, and what reaches the cache key — D15); ADR-298 D6, D7 (const is a name; the undeclared-name help — D2); ADR-298 D1 (a name may not be a reserved word); ADR-285 (a literal nothing constrains — D5); ADR-286 D15 (what pub means across a package — D12); ADR-296 D17 (the emitter is handed the spelling — D12); ADR-310 D3 (asset("…"): what a build may read, a separate question); ADR-311 (what a result has to be to cross to the program — the arrays, tables and rows that cross; a separate question); ADR-177 (a grammar run inside a comptime)

1. Question

Is there a declaration whose initialiser is guaranteed to be evaluated while the program is built — and if so, what is it called, where may it stand, and what may its initialiser do?

It could not default. The compiler already folds constants (ADR-285), so without a declaration a value computed at build time is an accident of the optimiser rather than a promise, and a malformed table is found by the program at its first read instead of by the build. Part II 10.2’s compile-time parsing, Part I 9.2’s Constants, the allowlist of ADR-310 and the fixed tables of ADR-311 all needed somewhere to stand. And once something runs while the program is built, the question of what it may do is the sandbox question: answered by accident if it is not answered on purpose.

2. How others do it

Language / tool Its answer What it costs there Source
Zig comptime names the stage; its immutable binding is const — the opposite assignment from here, for a language whose default is mutability none of the word confusion; evaluation inside the compiler from memory, not checked
Rust const is a value evaluated at compile time and is what the emitter here writes the word names immutability, which every Nikaia binding already has D8
Rust build.rs build-time code may print (println! to Cargo) and do anything a program may the build-script attack surface ADR-310 Q6 names D14, ADR-310
Zig @compileLog a build-time print exists as a builtin — D14; from memory, not checked
Sandboxed evaluation (a WebAssembly runtime, ADR-310 Q3’s table) permit anything, contain it a runtime in the dependency tree, a wasm32 target for every contributor and CI job, and NaN bit patterns that can make two correct builds differ D18, ADR-310 Q3

What does not transfer: Zig’s and Rust’s word assignments come from languages whose bindings are mutable, or whose const is the only immutable form. Here Part I 2.1 makes every binding immutable unless it says mut, so the word that distinguishes the declaration is the one that names a time.

3. Options, weighed

Question Option not taken Why it lost
Does the declaration add anything? none — rely on the fold a fold is may; a guarantee that silently degrades into it happened to be cheap is a defect (D4)
The word const, the neighbours’ word names immutability, which distinguishes nothing in this language (D1)
const once released keep it reserved, for a message the undeclared-name help gives the same message without forbidding the name (ADR-298 D7)
Unevaluable initialiser fall back to run time breaks D4 exactly in the way D4 is about (D6)
Item form an optional pub on the statement rule one rule handing back a Stmt or an Item says less about where it may stand (D9)
Two places a copy of the fold per place three chances for the two places to disagree about what a constant is; a place where NK1127 did not fire is a comptime that quietly means let (D10)
What a body may call a hand-kept allowlist of functions; anything sync the list rots; anything sync lets a build read its environment and write to the compiler’s output — of std’s 74 sync entries, ten touch something; see D13 and D14
cli::args at build time refuse it (touches = []) a build’s parameters are an input like its source; refusing them refuses what comptime is for (D14)
Termination a step budget now deferred, not rejected: a budget added later refuses only programs that already hang (D16)
Containment permit and sandbox the restriction of D13-D14 makes interpretation sufficient; a sandbox costs a runtime, a target and a determinism asterisk (D18)

The number that decided it. A table known at build time looks up in 9 ns against a HashMap’s 18 over 200 keys; the startup it saves is not the argument.

4. Decision

D1 — The declaration is spelled comptime, and the word names a time.

comptime LIMIT = 4 * 1024

comptime is a reserved word. It names the stage — evaluated while the program is built — and not a property of the binding, because every binding is already immutable unless it says mut (Part I 2.1).

D2 — const is a name, and a program that writes it as a declaration is told to write comptime.

The word is not reserved (ADR-298 D6). Written where a declaration would stand it is a name nothing declares, and that refusal carries write comptime X = …. Declared as a name — a parameter, a field — it is one.

D3 — It stands where an item stands, and inside a function body.

At item level it is package-private and takes pub to leave the package — Part I 9.2’s rule for Constants. Inside a body it is an ordinary statement, scoped like a let. The difference between the two places is where the name is visible, never what may stand to the right of the =. A comptime inside an impl is not decided (§6).

D4 — Its initialiser is evaluated while the program is built, and failing to is an error.

a let may fold; a comptime must, and says so where it cannot.

The declaration adds a demand, not an ability: the fold exists already (ADR-285). What reaches the program is the value, not the expression. An initialiser that cannot be evaluated is NK1127.

D5 — The type may be written and need not be.

Changed by D21: an integer comptime without a written type is an open number, whatever computed it.

Inferred as a let’s is, including ADR-285’s rule for a literal nothing constrains; an integer is D21’s.

D6 — What the compiler cannot evaluate is refused by name; it is never evaluated at run time instead.

What may stand in the initialiser is what the build-time evaluator reads under D13-D14; the shapes a result may have to reach the program are ADR-311’s. A shape the evaluator does not read is NK1127 (this compiler cannot evaluate it), which is distinct from D13-D14’s NK1152 (the rule says no): a reader waits for one and changes the callee for the other.

D7 — A comptime is a value, not a place.

It has no address, nothing may point at it, and there is no mut form. There is no second declaration for a build-time value that does have an address.

D8 — The word does not travel downwards: the emitter writes Rust’s const.

D1 and D2 are about one word; everything D3-D7 says about the declaration’s meaning is unchanged by it. What the emitter writes is Rust’s const, because that is Rust’s name for the same slot.

D9 — The item form is a rule of its own in the grammar.

Not an optional pub on the statement: the two produce different things, a Stmt and an Item, and pub belongs to the item rule because Part I 9.2’s rule for Constants is what it means there.

D10 — One function evaluates both places.

The fold, NK1127, the declared-type check and the spelling handed to the emitter are one thing in one place, so the two places cannot disagree about what a constant is.

D11 — Item-level constants are bound in a pass before the item loop, in a frame that is never popped.

Before, because a function declared above a constant sees it as much as one below: an item is visible in its whole scope. Never popped, because the frame has to be under every function.

D12 — The emitter is handed the spelling, as for the statement form, and pub reaches Rust’s pub.

const MAX: i32 = 1000;. The emitter knows no types (ADR-296 D17), and Rust’s const will not take a missing one. pub reaches Rust’s pub, which is what another package reads (ADR-286 D15).

D13 — A build-time body must be sync.

It may not suspend (ADR-288 D21). It may still block a thread, which at build time is slow rather than wrong. The column is inferred for std and for the program alike.

D14 — Its touch set must be empty, or exactly args read.

cli::args passes on purpose: a build’s parameters are an input like its source, and at build time cli::args means the arguments of whoever runs the build. Everything else is refused — no output written, no locks taken — as a consequence of the boundary, not a judgement about println. The rule is read off the ledger’s two columns, not off a list; a callee whose body this unit does not have is neither allowed nor forbidden but unevaluable. A breach is NK1152.

D15 — The determinism guarantee gains a term, and the cache key gains a field.

ADR-005 D8 reads: same source, same toolchain and same parameters produce the same bytes. That holds only if the parameters reach the cache key, so they are one field of the build’s switches beside the others (ADR-021).

D16 — No step budget, and the call depth is bounded.

Changed by ADR-321 D7: there is a step budget, counted in loop turns and calls.

sync says a body never pauses, not that it ends, so a build-time loop that does not end hangs the build. That cost is accepted and the direction is safe: a budget added later refuses only programs that were already hanging. A recursion without a base case is a different failure — it would overflow the compiler’s own stack — so the call depth is bounded at a limit no terminating program meets, and the message says so (NK1152).

D17 — Memory stays open.

Changed by ADR-321 D10: a build-time run’s memory is bounded, 4 GiB by default.

A build-time body that builds a table must allocate, and neither column bounds how much; ADR-321 D10 bounds it.

D18 — There is no sandbox: restrict, then run.

Changed by ADR-321 D1: what D13-D14 allow is compiled and run, not interpreted.

D13 and D14 are the restriction ADR-310 Q2 asked for, so what they allow is compiled and run (ADR-321), with no sandbox around it. Q3 (what to sandbox in) and Q1 (a grammar as a closed world making a sandbox affordable) close with it: no WebAssembly runtime, no wasm32 target required of every contributor and CI job, no NaN asterisk on determinism.

D19 — At item level a constant may read one declared below it; inside a body, written order.

Added October 4, 2026 (#380).

comptime B = A + 1      // fine: A is an item, declared below
comptime A = 41

fn main() {
    comptime D = C + 1  // NK1117: C is not declared yet
    comptime C = B * 2  // fine: B is an item
}

Each place behaves like its neighbours. A comptime that stands where an item stands is an item, and items are order-independent: a function may call one declared below it, so a constant may read one. A ring cannot be evaluated and is refused with NK1168, a constant worked out from itself. A comptime inside a body stands among lets, and a body is read top to bottom, so it is read in written order; a name declared further down is NK1117 alone, as it is for a let. A body-level constant may read an item-level one.

Until a program needs another order. Nothing yet asks a body-level constant to read one below it; a program that does is the evidence for reopening the question.

D20 — comptime marks the name, and there is no comptime expression.

comptime APP = asset("schema.sql")      // not: let app = comptime asset("schema.sql")

The word stands where the declaration’s word stands, as const does in Rust and constexpr in C++: it says that this name is fixed while the program is built. A function is never marked; whether it may run then is read off its ledger (D13, D14), so the one place a program states the demand is the name that needs the value.

Rejected: comptime as a prefix to an expression (Zig’s comptime f()), beside the declaration or in place of it. Beside it, it is two spellings of one thing. In place of it, it needs a let where an item stands, where a let without the word could only be refused, and a rule for how far the word reaches (comptime f(x) + 1). What it adds is a build-time value without a name, and a name is what ADR-299 D11 identifies a schema by.

D21 — An integer comptime is an open number: each use takes it in the type the use asks for.

fn big(n: i64) -> i64 { return n * 1000000000 }
comptime A = big(1)     // 1000000000
take_i32(A)             // an i32 here: the value fits
take_i64(A)             // an i64 here
take_u8(A)              // NK1116: 1000000000 does not fit in a u8

What reaches the program is the value (D4), so the value is what a use is checked against, not the type of the function that computed it. Each use decides its own type, and two uses that ask for two types are no conflict, as they are for a let (NK1200): the value is one, and each use receives it in its type.

Where no use asks, the type is the expression’s: a call’s is its result type, and an expression of literals alone takes the first type that holds it (ADR-285 D21, D22). So no type in the program waits for an evaluation: the type of every use is known before the value, and the value is needed only for the check that it fits and for writing it down. Evaluation may run beside the rest of the build.

A written type pins it: comptime A: i64 = big(1) is an i64 at every use.

Inside the body that computes it, the body’s types hold. sq(100000) with fn sq(x: i32) -> i32 overflows an i32, and the build stops as the program would (ADR-285 D5); only the finished value is open.

Rejected: the type of the expression at every use, which fixes a known value to a width it does not need; and where nothing asks, the first type that holds the value, which makes a type wait on an evaluation. Go and Zig keep a call’s type and leave only a literal expression untyped; here every value the build computes is a value the build knows.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.