Status: Superseded by ADR-310 — its D1-D8 are D4-D11 there
Date: September 14, 2026
Target Version: Nikaia 0.0.8
Answers: ADR-026 Q5 (does reading need to be enabled?) and the
filesystem half of its Q6. That record stays Open: what a build-time body may
compute is not decided here
Related: ADR-026 D1 (I/O belongs to the compiler) and D2 (a
compile-time path stays inside the project), ADR-021 D13 (the asset
dimension of the cache key, which this is the producer for), Part II 10.6
(comptime X = Json.value(asset("config.json"))),
ADR-116 (asset("…") is the spelling),
ADR-287 D6 and ADR-287 (the build-time evaluator
the read happens inside, and what bounds it)
The spelling has been in the specification since Part II 10.2 was written:
comptime CONFIG: Json::Value = Json.value(asset("config.json"))
and ADR-026 D1 already decided that the compiler does that reading rather than the evaluated body. So a build-time body performs no I/O of its own: it is handed bytes the compiler fetched, named at the site by a literal.
What D1 left open is Q5, and Q5 is the question with teeth. D2 keeps a build out
of /etc/passwd; it does not keep a dependency’s grammar out of your
secrets.toml, which sits inside your project root exactly where D2 allows
reading. Q5 offered three options — a manifest declaration, a per-invocation
flag, or no grant at all, relying on the lockfile record to make a new read
visible after the fact.
The default is the strict one. With no --allow-read-from-list, an
asset("…") under a comptime is refused, and the whole class of compile-time
file reading is off.
This is the part worth having even for a project that never intends to read anything: “this build reads nothing while building” is not a claim somebody has to make, keep true, and be believed about. It is what happens when nothing is passed.
nikaia build --allow-read-from-list=build-reads.txt
One path per line; # begins a comment. The list is a file rather than a
repeated flag because a list on a command line lives in whoever’s CI
configuration runs the build, and a list in a file lives in the repository, where
a change to it is a diff.
The flag says a list is in effect. The list says which files. The asset("…")
literal says which one this line reads. A read that is missing any of the three
is refused, and the three are deliberately not derivable from one another:
Two of the three are committed, so a change to either is visible in review. The third is not, and that is what it is for: a build may be run with the reads switched off without editing anything.
from takes a string literal under a const, never a name and never an
expression that folds to one. If a path could be computed, “named in the code”
would stop being decidable by looking at the line, and the property the three
namings buy is exactly that it is decidable by looking.
The cost is real and is accepted: a build that wants config/linux.toml and
config/wasm.toml writes both, in the code and in the list.
A glob is a read of something nobody named — the directory — and it would let a list entry stand for files that do not exist yet. Both halves defeat D3. A build that wants ten files lists ten files.
A from line inside a dependency is checked against the list of the build that
is running, not against anything the dependency ships. A package cannot bring
its own permission.
This is stricter than resolving paths relative to the package that wrote them,
and the difference is the point: a dependency that reads a file makes the
consumer write a line for it, which is where the consumer finds out. The friction
is the feature — it is the case Q6 calls the build.rs attack surface, answered
by making the read impossible to add quietly rather than merely recorded after
the fact.
It is a file the build read, so it belongs in the asset dimension with the files it names (ADR-021 D13). Changing a line must invalidate, or a build that stops reading a file keeps the old answer.
A list that may hold names nothing uses decays into everything we ever needed, which is how an allowlist stops being read. Naming the unused entry at the end of a build keeps it the size of the truth.
Three places is the cost and it is charged on purpose. Nothing here is cheaper than the alternatives; it is more visible than them. ADR-021 records every file a build read in a committed lockfile, which makes a new read visible after it happened; this makes it impossible before.
The lockfile’s asset dimension gets its producer, which is the thing it has
been carrying through Key::build with a comment saying nothing reports assets
yet.
const is the prerequisite and does not exist. Part II 10.2’s status note
says it plainly: there is no const in the parser, at item level it is a parse
error, and in a body const X = 1 parses as two statements. Until that changes
there is no place for a from line to stand and nothing for this check to check.
The word is also not reserved.
What is still open is what a const body may compute.
ADR-026 Q4 asks it, and this record does not answer it: whether the
body must be sync, what a step budget looks like, and what bounds its memory
are untouched here. This decides where the bytes come from, and nothing about
what happens to them afterwards.
All of D1 to D8, at 0.0.123. The order this record named has been walked:
comptime arrived with ADR-287, Q4’s evaluation rules are
ADR-287’s two ledger columns, and the evaluator that runs the
initialiser is ADR-287 D6’s second stage — so the check finally
had a const to hang on.
crates/nikaia/src/assets.rs holds the list and the read. Reads::none()
is D1, and it is what every caller that says nothing gets: every test, every
--input without the flag, every build of a project that never asked.--allow-read-from-list=FILE is D2, and it reaches the rustc wrapper
through NIKAIA_ALLOW_READ_FROM_LIST — the wrapper is a second process that
lowers the same file again, and two halves of one build must not disagree
about which files it may read. That is also how D6 holds: every member of the
workspace is lowered under one environment, so a dependency’s asset("…") is
checked against the list of the build that is running.NK1175 is D1 and D3, with a reason per shape and a way out that differs
with it: add the flag is not the answer to a path missing from the list, and
add the line is not the answer to a build run with the reads switched off.NK1176 is D4, and it is answered before the argument is evaluated —
a name that folds to "config.json" is a path that was computed, and reading
it would make named in the code something a reader cannot decide by looking
at the line. The test that says so uses a path which is listed, is on disk and
would have been allowed: if the check ran on the value there would be no
finding at all.*.toml in the list names a file called *.toml, and a *.toml in the code
is a path like any other.What crosses is text. asset("…") evaluates to the file’s contents and a
comptime hands that to the program as the &str a const holds
(ADR-079 D1) — so bytes that are not UTF-8 are refused by name
rather than converted. Bytes that are not text have no crossed form yet, and
that is ADR-079 D5’s blob rather than this record’s.
What this does not add is a sandbox, which is the part worth saying out loud. ADR-287’s two columns already bound what a build-time body may do; this bounds what it may read, and the two are separate questions with separate answers. ADR-026 stays open for the third: what bounds a build-time body’s memory.