asset("…"), the Path Stays in the Project, and from Is a NameStatus: 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.
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.
| 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.
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.
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.
.. 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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
asset
yields text, and bytes that are not text are refused.from and a word it does reserve is escaped (ADR-298 D9).asset may one day mean these bytes, embedded outside a
comptime. It is refused there (D3).