ADR-075: A Build-Time Body May Call What Is sync and Touches at Most the Build’s Parameters

Status: Superseded by ADR-287 — its D1-D6 are D13-D18 there Date: September 14, 2026 Target Version: Nikaia 0.0.8 Answers: ADR-026 Q4, and with it Q1, Q2 and Q3. That record stays Open for one thing only: what bounds a build-time body’s memory Related: ADR-067 D1 (sync means one thing: it never pauses) and D2 (touches is inferred over the call graph), ADR-073 D5 (which staged this and did not answer it), ADR-072 (where the bytes come from), ADR-005 D8 (byte-determinism, enforced by CI double-builds), ADR-021 D5 (what reaches the cache key)


1. Context

ADR-026 Q4 has been the question everything else in that record was downstream of: what is a program allowed to do in const? It asked for pure functions or anything, named allocation as a requirement rather than an option, and asked for a step budget and for nothing that observes time, environment or randomness.

Two of those are no longer questions, because they became columns. Since ADR-067, sync means exactly one thing — it never pauses — and touches is inferred over the call graph for every function a .nika file declares. The restriction Q4 asked somebody to invent already exists twice over and is maintained by the compiler rather than by a list.

What the ledger says when you count it. Of std’s 74 sync entries, ten touch something:

cli::args, Args::nth      "args read"
print, println            "stdout write"
eprint, eprintln          "stderr write"
Locked::get/set           "lock read" / "lock write"
SharedMut::get/set        "lock read" / "lock write"

So “anything sync” alone would let a build-time body read the environment it was invoked in and write to the compiler’s own output. That is what makes the second column necessary, and it is also what makes the exact boundary a decision rather than a derivation.

2. Decisions

D1 — It must be sync.

The first half of the rule, and it costs nothing to enforce: the column is inferred already, for std and for the program alike.

What sync buys here is narrower than it sounds and is worth stating, because ADR-067 D1 deliberately made the word mean one thing: a build-time body may not suspend. It may still block a thread — the panic hook is the proof, it must be sync and may block. At build time that is slow rather than wrong, which is why the word is enough for this purpose and would not be for another.

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

Not touches = []. cli::args passes, deliberately: same parameters, same code. A build’s own parameters are an input like its source is, and a build-time body that shapes a table differently for two declared parameters is doing the thing const is for.

Everything else in the list above is refused: a build-time body does not write to the compiler’s output and does not take locks. That refusal is a consequence of the boundary rather than a judgement about println — a build-time print is a reasonable thing to want, build.rs has one and Zig has @compileLog, and it is a later decision with its own reasons rather than something this record settled against.

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

ADR-005 D8’s byte-determinism is a hard guarantee, enforced by CI double-builds, and D2 changes what it is a guarantee about:

same source and same toolchain and same parameters produce the same bytes.

That is not a weakening as long as the parameters are carried. They are not today: Choices holds exactly two fields, the build shape and the backend, and its own doc comment says what to do about that — “Adding a field here means adding a line to Key::build, which is the point of keeping them in a struct of their own”. So this costs one field and one line, and skipping it would make the cache serve a build made with other parameters, which is the failure mode ADR-021 exists to prevent.

And cli::args means the same thing in both modes, which is why it is not two functions: the arguments of whoever is running me. At program time that is the program; at build time it is the build.

D4 — No step budget, for now, and here is what that costs.

Q4 asked for one and this record does not add one. The cost is not hypothetical and ADR-026 Q6 already wrote it down: “a build that hangs with no diagnostic is worse for the local developer than one that stops and names the call path”. A const that does not terminate hangs the compiler silently, and sync does not rule that out — it says a body never pauses, not that it ends.

Deferred rather than decided against, and the direction is safe: a budget added later refuses only programs that were already hanging. Nothing built now has to anticipate it.

D5 — Memory stays open, and it is the last thing in that record.

Neither column says anything about allocation, and Q4 named allocation as a requirement rather than an option — a build-time body that builds a table must allocate. So there is no bound on how much, and ADR-026 stays Open for exactly this.

It is the mildest of the three worries it arrived with: a build that exhausts memory fails loudly, where one that reads a file quietly does not and one that hangs says nothing at all.

D6 — There is no sandbox, and three questions close with that.

ADR-026 Q2 asked restrict-and-interpret, or permit-and-sandbox. D1 and D2 are the restriction, so: restrict and interpret. Q3 asked what to sandbox in, and its own table says AST interpretation is the smallest and is viable only if Q2 restricts — which it now does. Q1, about a grammar being a closed world making a sandbox affordable, is moot when there is no sandbox.

What goes with the sandbox is worth naming, because each was a real cost: no WebAssembly runtime in the dependency tree, no wasm32 target required of every contributor and every CI job, and no asterisk on determinism for NaN bit patterns, which Q3 flagged as able to make two builds both correct and different.

3. The gap that holds this up in practice

Twenty-four of std’s 74 sync entries have no touch answer at all, and the polarity is fail-closed, so each of them reads “touches everything”. Among them:

HashMap::new   HashMap::get   HashMap::keys   HashMap::len
HashMap::entry   f64::sqrt   f64::abs   fs::Mapped::deref

Under D2 a build-time body could not use a map — which is the thing this feature is for. That is not a design problem: it is twenty-four missing lines in the shipped ledger. ADR-067 D2’s inference does not reach them, because it walks Nikaia bodies and these entries are backed by Rust, so they are written by hand. HashMap::get touches nothing, and saying so is one line.

It is on [#205](https://github.com/Nikaia-Language/Nikaia/issues/205) rather than here.

4. Consequences

docs/open-decisions.md loses its oldest entry, and what is left of ADR-026 is one bound rather than a design space.

The two columns get a third consumer, after the ordering pass and the lock-inside-a-lock refusal, and it is the first that uses both at once: sync says a body does not wait, touches says it does not reach.

The file half and the evaluation half now meet. ADR-072 says where the bytes come from, this says what may be done with them, and ADR-073 says where the declaration stands. What is missing is the declaration itself.

5. What is built

D1 and D2, and they are what let ADR-073 D5’s second stage be written at all: comptime N = double(21) evaluates, because the interpreter asks the two columns this record names and nothing else.

The rule is read off the ledger and not off a list, which is the sentence that decided the shape of the work. sync says the body never pauses and the touch set says it reaches nothing but the build’s own parameters; both are derived for every function the compiler sees, so crates/nikaia/src/build_time.rs decides nothing about safety — it reads what was decided here. A callee this unit does not declare is neither allowed nor forbidden but unevaluable: there is no body to run, and saying you may not about a function whose body is somewhere else is a claim this cannot make.

NK1152, and it is deliberately not NK1127. That one says this compiler cannot evaluate it — the staging talking — and this one says the rule says no. A reader does two different things about them: wait for a stage, or change the callee.

And D4’s cost needed one neighbour. That decision has no step budget and writes down what it costs: a body that does not terminate hangs the build. A recursion that does not terminate is a different failure — it overflows this compiler’s own stack, and a compiler that falls over is not the hang D4 accepted. So the call depth is bounded, at a limit no terminating program meets, and the message says that is what it is. The work is still unbounded.

What is left of this record is D2’s second half in the other direction: a build-time print. §3 calls it a reasonable thing to want and a later decision with its own reasons; today it is refused as a consequence of the boundary, which is what that paragraph said it would be.

The order for the rest is unchanged: ADR-072’s allowlist and dsl … from "…" with it — plus the twenty-four ledger lines of §3, which are needed before the first useful const and are not needed before the first one.

This site is open source. Improve this page.