ADR-310: A Build Reads a File Only When the Code, a Committed List and the Invocation All Name It — the Code Says asset("…"), the Path Stays in the Project, and from Is a Name

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: what may a build read while it runs, and who has to agree to it — whether reading must be enabled at all, where the grant is written, and whether a dependency can bring its own; where may a build-time path point; how does the code spell a build-time read; and is from a reserved word Related: ADR-287 — the comptime binding the read stands in, what its body may do (D13-D14), that there is no sandbox (D18), and its memory (D17), which ADR-321 D10 bounds; ADR-021 D2, D7, D13 — the lockfile records every file a build read, and every input is a key dimension; ADR-311 — what a build-time result has to be to cross to the program; ADR-298 D1, D3, D9 — the reserved list, a reserved word is a name after :: and ., and a name the language below reserves is escaped; ADR-178 — compiler-side staging, a different feature from this one; ADR-108 — a run-time path names its root at the call (fs::rename(from:, to:, root:)); ADR-258 — the four weights. Part II 10.2 holds the rule; Part I 2.1 the reserved list; Part III 13.1 the lockfile’s asset hashing and 17.1 fs.

1. Question

When a program is built, which files may the build read, who has to say so, and how does the code spell the read?

A comptime binding (ADR-287) can embed what a file says — comptime CONFIG: Json::Value = Json::value(asset("config.json")) — and a grammar run at build time can reject a malformed file before the program ever runs. That is the use. The risk is the same sentence read the other way: a build that reads files is a build that can read your files. Keeping a build out of /etc/passwd is not enough; a dependency’s grammar reading secrets.toml, which sits inside your project, and embedding it in a binary you ship is the case worth deciding against. Without an answer, either nothing may be read at build time or anything may.

The spelling is part of the question because a keyword, from "config.json", would cost the pair of names every API reaches for: fs::rename(from:, to:) could not be written in the language.

2. How others do it

Language / tool Its answer What it costs there Source
Zig comptime code does no I/O; reading a file is a separate declarative capability whose path the compiler resolves (@embedFile) compile-time-heavy code makes the compiler slow and memory-hungry from memory, not checked
Jai compile-time execution is broadly open, I/O included whatever the build runs, it may read from memory, not checked
Rust const fn is restricted and heapless; build.rs and proc-macros run native, unrestricted, outside the type checker the build-script attack surface: a pull request that adds one runs with full privileges on every reviewer’s machine and in CI — the standard route for supply-chain attacks from memory, not checked
Terra, MetaOCaml staged programming, the older field this sits in — from memory, not checked
Any language with a committed lockfile of build inputs (here, ADR-021 D2) every file read is recorded with its hash, so a new read is a diff in review makes a read visible after it happened; prevents nothing ADR-021

What does not transfer: Rust’s split exists because const fn cannot allocate; a comptime body here can (ADR-287), so the build-time read does not need a second mechanism to live in. And none of these spells the read as a keyword: Zig’s is a builtin call, which is the shape D3 takes. Rust does not reserve from either.

3. Options, weighed

Who grants a read.

Option What is written Why it lost / won
No grant; the lockfile records what was read nothing makes a new read visible after it happened and must not be sold as preventing it; a dependency’s read of secrets.toml is a lockfile diff only after the secret is in the binary
Asset directories declared in the manifest a line per directory a directory is a read of something nobody named, and stands for files that do not exist yet (D10)
A per-invocation flag listing the files --allow-read=a,b the list lives in whoever’s CI configuration runs the build, not in the repository, so a change to it is no diff anyone reviews
Each package grants its own reads a list shipped with the package a package brings its own permission, which is the attack (D11)
The code, a committed list, and the invocation each name the file (chosen) asset("config.json"), a line in build-reads.txt, --allow-read-from-list=build-reads.txt three places is the cost, charged on purpose: two are committed, so adding a read is a diff a reviewer sees, and the third lets a build be run with reads off without editing anything (D4-D6)

Where a path may point. Normalising a path and then checking the result was rejected: a check that argues about a normaliser’s output is where path-confinement bugs live (a/../../b, encoded separators, one platform’s idea of a root against another’s). Refusing the component is a rule about the text the author wrote, which has one reading (D2).

How the evaluator gets the bytes. Handing it a path, a handle or a pointer into memory it laid out was rejected: an evaluator that has a filesystem and is configured not to use it is a policy that can be misconfigured; one handed the text cannot reach a file because there is nothing to reach it with (D1).

How the code spells it.

Option Source Why it lost / won
A reserved keyword Json::value(from "config.json") buys one phrase and costs the most common name pair in APIs; a reserved word is a name only after :: and . (ADR-298 D3)
A std function fs::embed("config.json") std would have to describe a body that may not run at run time, and a program could shadow it
A call the compiler recognises (chosen) Json::value(asset("config.json")) reads as what it is, a call, beside the call that consumes the bytes; the word is the one Part III 13.1 uses for these files (D3)

Under ADR-258: the special case pays (D1 of 258) — a build that reads nothing writes nothing, and a build that reads ten files writes thirty lines; the compiler does the work (D2 of 258) of checking all three places and naming the one that is missing; and the cost at run time is nil, since every read happens while building.

4. Decision

D1 — The compiler reads the file; a build-time body is handed its text, never a path.

asset("…") evaluates to what the file holds, read by the compiler. The body that consumes it never receives a path, a handle or a filesystem, and so has nothing to reach another file with. Capability-free by construction beats capability-free by settings, and it makes what did this build read? answerable in one place. What the body may otherwise do is ADR-287’s (D13-D14); there is no sandbox around it (ADR-287 D18), so this is the whole of how a build-time body reaches a file.

D2 — A build-time path stays inside the project, and .. is refused rather than resolved.

The path is read from the root of the build that is running, and must stay under it. A path containing a .. component, or an absolute path, is refused as malformed — not normalised and then checked. Both shapes are visible in the literal, so whether a path leaves the project is decided by looking at the line, as D6 requires of every other property of the read.

D3 — The code spells a build-time read asset("…"), and it is the compiler’s.

comptime CONFIG: Json::Value = Json::value(asset("config.json"))
comptime ROUTES = Routes::table(asset("routes.txt"))

asset is recognised inside a comptime initialiser and is not a std function: there is no body to describe, and a program cannot shadow it. Its argument is a string literal (D6), and that literal is what the list is checked against (D5). Written anywhere else it is refused, and the refusal says that a file read while the program runs is fs::read. comptime says when, asset("…") says where the bytes come from, and the call around it says what is done with them.

D4 — A build given no list reads nothing.

With no --allow-read-from-list, every asset("…") is refused and the whole class of build-time reading is off. This build reads nothing while building is then not a claim somebody has to make, keep true and be believed about; it is what happens when nothing is passed.

D5 — The switch names a file, and that file names the files.

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 file lives in the repository, where a change to it is a diff.

D6 — A file must be named in all three places.

The flag says a list is in effect. The list says which files. The asset("…") literal says which one this line reads. A read missing any of the three is refused, and the refusal names the one that is missing — 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 reads switched off. The three are deliberately not derivable from one another:

D7 — The path is a literal; a name or an expression that folds to one is refused.

The check is against the literal as written, and it is answered before the argument is evaluated: a name whose value is "config.json" is a computed path and is refused even where that path is listed and on disk. If a path could be computed, named in the code would stop being decidable by looking at the line, and that is the property the three namings buy. A build that wants config/linux.toml and config/wasm.toml writes both, in the code and in the list.

D8 — No patterns, no directory listings.

A glob is a read of something nobody named — the directory — and would let a list entry stand for files that do not exist yet. A * in the list or in the literal is a character of a file name. A build that wants ten files lists ten files.

D9 — The list binds the whole build, dependencies included.

An asset("…") 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: 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 — the build-script attack surface, answered by making a read impossible to add quietly rather than recorded after the fact.

D10 — The list is part of the cache key.

Every file a read took is an asset of the build (ADR-021 D2, D7), and the list’s own digest is a dimension of the key beside the build’s switches, because it binds the whole build. Changing a line invalidates, or a build that stops reading a file keeps the old answer. A build with no list is a different build from one with an empty list: the first cannot read at all.

D11 — An entry nothing reads is a warning.

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 the build, where the whole set is known, keeps it the size of the truth.

D12 — from is an ordinary name.

It is not on the reserved list, in Part I 2.1 or in the parser. fn rename(from: Path, to: Path), let from = 10 and p.from are what they look like. A word whose only use is a phrase the language can say another way does not earn a place on the list. A program that writes dsl X from e is answered with a grammar is entered by a call, from the token rather than from a reservation.

D13 — std writes from: where it means it.

fs::rename(from: Path, to: Path, root: Root) and fs::copy(from: Path, to: Path, root: Root) are written as Part III 17.1 has them.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.