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 ParametersStatus: 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)
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.
| 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.
| 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.
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).
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.
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).
a
letmay fold; acomptimemust, 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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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).
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.
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.
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.
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.
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.
NK1127 (cannot evaluate) and NK1152 (not allowed, and the
call depth).sync says
a body does not wait, touches that it does not reach.comptime inside an impl — associated constants are a question about
where a name lives (D3).pub comptime is recorded in another package’s ledger — the
pub reaches Rust; whether nikaia.contracts carries it is
ADR-020’s question.