ADR-026: Compile-Time I/O — What a Build May Read, and What May Run While It Reads

Status: Superseded by ADR-310 — its D1-D2 are D1-D2 there; its open questions are answered by ADR-287 D13-D18 and ADR-310 D4-D11, and what remains open is ADR-310 §6 Related: ADR-296 D3 (one syntax for both modes), ADR-021 D2/D7/D13 (the cache dimension that has no producer), ADR-005 D8 (byte-determinism, which several options quietly break), staging-candidates.md (where Tier 1 stands), ADR-178 (Tier 1 withdrawn, and what would reopen it)

0. What “Open” means here

The design space is staked out, two things in it are settled, and the rest is written down as questions, because they are not answered. An open ADR that quietly decides something is worse than no ADR at all — the next reader would take a sketch for a commitment.

Nothing here is implemented. This exists so that whoever implements it starts from the constraints rather than rediscovering them.

1. Context

Compile-time I/O is specified in three places and built in none.

And one fact that shapes every option: a grammar is not declarative. An alternative carries an arbitrary Nikaia block that builds its value, so running a grammar at compile time means running arbitrary user code at compile time.

Not the same question as ADR-010. That decides how the provenance of runtime input picks a hasher; this is about what the build may touch. They share the word “input” and nothing else, and conflating them would let a runtime-safety argument stand in for a supply-chain one.

2. Decided

D1 — I/O belongs to the compiler; evaluation belongs to the sandbox.

The compiler opens the file and hands bytes to whatever evaluates the grammar. The evaluator never receives a path, a handle, or a filesystem.

A sandbox that has filesystem access and is configured not to use it is a policy that can be misconfigured; one handed a byte slice cannot reach a file because there is nothing to reach it with. Capability-free by construction beats capability-free by settings, and it makes “what did this build read?” answerable in one place.

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

Paths resolve relative to the source file that contains them, must land inside the project root, and a path containing .. is rejected outright as malformed — not normalised and then checked.

That last clause is the whole decision. A check that normalises first is a check that argues about the result of a normaliser, and normalisers are 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.

Open within this decision: symlinks. A link inside the project pointing outside it satisfies D2 as written and defeats its purpose. Resolving links before the check reintroduces exactly the normaliser argument D2 avoids.

3. Two tiers, and only the second is expensive

The most useful thing to say about this space is that it holds two features, not one, and conflating them forces the harder one’s requirements onto the easier one.

Tier 1 — compiler-side staging. Written by us, in the emitter. No new security model, no determinism problem, no toolchain requirement, no fuel budget. Perfect hashing for a route table, a regex becoming a DFA, a state machine flattened.

Tier 2 — user-visible compile-time execution. The user writes a tool that runs during the build. Everything in §4 belongs here and only here.

Tier 1 waits on nothing. Worth stating flatly, because the applications usually cited as the reason for Tier 2 are Tier 1 work: perfect hashing over a hundred routes is a few hundred lines in the emitter with no evaluator of any kind. What bounds how much the compiler can choose is ADR-005 D8: the ledger is a pure function of source and toolchain, so the emitter may decide from those and nothing else — autotuning is out, a threshold fixed at compile time is in. Where Tier 1 stands against that bound is surveyed in staging-candidates.md.

And waits on nothing turned out not to mean is worth doing. The survey measured every candidate it could find and closed all of them, three against its own prediction — so Tier 1 is withdrawn (ADR-178 D1), and what would reopen it is a candidate with a measured crossover (D2). This paragraph is still true as written: nothing blocks Tier 1. Nothing pulls at it either.

The precedent is in this repository. ADR-010 has the compiler choose a map’s hasher from the provenance of its keys, with nothing in the source mentioning hashing, and it is worth 26 % of the flagship’s instructions. An abstraction whose cost the compiler removed because it knew something — no sandbox, no JIT, no comptime, and it shipped.

3.1 — Tier 1 must be able to emit specialised code

Not “we will build a regex compiler”, but: the architecture must not preclude one. Already satisfied, by two mechanisms both in use:

Two caveats belong here rather than in a benchmark six months from now. Table-free is not automatically faster: a DFA as straight-line code trades a data-dependent load for an indirect jump plus instruction-cache pressure, and loses that trade as the state count grows — which is why some tools generate code and others use lazy DFAs with tables. And “no branch mispredictions” is backwards for the state machine itself, since an indirect jump per transition is what a predictor handles worst. The defensible claim is that the win is where the analysis removes states: a fixed literal collapses to a comparison, a bounded pattern unrolls.

3.2 — Tier 2 pulls against the thing ADR-021 was built for

Compile-time execution makes builds slow, and ADR-021 exists because build speed matters. The complaint one hears about Zig — that comptime-heavy code makes its compiler slow and memory-hungry — is therefore not a competitor’s weakness to exploit but a warning about the feature itself, on the same terms.

What helps is the cache: an unchanged input skips the whole evaluation, which converts a per-build cost into a per-change one. That mitigation is already built.

4. The design space (all Tier 2)

Q1 — A grammar is a closed world, and that is what makes a sandbox affordable.

A grammar maps bytes to a value of its own type: it needs neither the surrounding program’s AST, nor its types, nor its symbol table. So an external grammar can be translated in complete isolation, and the boundary is a layout the host reads out of the guest’s memory, not a text format to parse back.

Two consequences:

Open: whether to commit to that split.

Q2 — Matching is pure; actions are not. Which needs a sandbox?

Matching is total and effect-free and could be interpreted directly over the grammar already in the AST — a matcher that only matches cannot do anything else. Actions are arbitrary Nikaia, and are the entire reason a sandbox is under discussion.

So a cheaper design hides here: restrict what an action may do in const context to a pure, total subset, and the sandbox question disappears with the dependency it would add. The cost is a rule authors must learn and a second dialect-ish distinction — precisely what ADR-296 D3 was avoiding.

Open: restrict-and-interpret, or permit-and-sandbox.

Q3 — If it is sandboxed, in what?

  shape cost
a pure-Rust Wasm interpreter small dependency, easy to embed slow
a Wasm JIT fast large dependency tree and build time
our own bytecode VM no dependency no ecosystem either; a VM is a long-lived thing to own
AST interpretation smallest only viable if Q2 restricts

The direction of that table is not the obvious one. An interpreter is roughly an order of magnitude off a JIT, which removes exactly the speed advantage that would justify Tier 2’s machinery over a plain bytecode VM. A JIT brings a JIT-sized dependency. Worse for both: the grammar has to be translated to Wasm before anything runs, and for a single evaluation that translation dominates the execution it was meant to accelerate.

The honest form of the claim is conditional: the Wasm advantage exists for large workloads with an already-cached module, and is negative otherwise. ADR-021 supplies the caching half; the workload half is an assumption nobody has measured — and an equally plausible one, Cranelift as the dev backend, measured 25 s against 26 s.

Two costs easy to discover late. A toolchain requirement: every contributor and CI job would need the wasm32 target. And determinism has an asterisk: Wasm with no imports is deterministic except for NaN bit patterns, which may vary — which matters here because ADR-005 D8 is a hard guarantee enforced by CI double-builds, so a grammar whose actions do float arithmetic could produce two different builds that are both correct.

Q4 — What is a program allowed to do in const?

The largest open question, and the one that must be answered before Q2 or Q3, because it decides what the evaluator must support.

Q5 — Confinement is not permission. Does reading need to be enabled?

D2 stops a build reading /etc/passwd. It does not stop a dependency’s grammar reading your secrets.toml — that file is inside your project root, exactly where D2 allows reading — and embedding it in the binary you ship.

Three options: asset directories declared in the manifest, so the grant is reviewable and versioned; a per-invocation flag, the most friction; or no grant, relying on the record.

The third deserves more credit than it first appears to, because ADR-021 already built the mechanism: every file a build reads is recorded in the lockfile with its hash, and the lockfile is committed. A dependency that starts reading something new cannot do it quietly — it is a lockfile diff in review, next to the change that caused it. That does not prevent the read and must not be sold as if it did. It converts an invisible capability into a visible one, which is weaker and much cheaper.

Q6 — Trust changes the default, not the mechanism. Where does the line go?

Everything above treats Tier 2 code as potentially hostile, which is wrong as a blanket assumption: code you wrote runs with your privileges anyway. The obvious repair — local code native, foreign code sandboxed — puts the line in the wrong place and mistakes two costs for security costs.

That leaves exactly one cost that genuinely tracks trust: memory and filesystem isolation.

A direction worth considering, recorded as an option and not a decision: do not split on origin; declare capabilities. One mechanism and one rule set; determinism and fuel for everyone; isolation the single capability a project may explicitly give up in its manifest. The local developer pays the isolation cost only by choice — and the choice is a line in a committed file that a reviewer sees and CI can refuse, rather than an implicit property of a directory. The same move ADR-021 makes with the lockfile.

5. Prior art

Both poles of Q2 and Q4 are occupied by working languages, which makes this a choice rather than an excuse.

6. Considered and rejected

7. What answering this buys the cache

ADR-021’s asset dimension finally gets a producer, and whatever Q2/Q3 decide is paid for at most once per change.

D13’s rule applies unchanged and is worth restating here, because it is easy to violate: every input the evaluation reads must be a key dimension. The asset’s bytes are. The grammar is, through the source hash. The evaluator’s own version is, through the compiler fingerprint. Anything a future const may observe joins that list in the same change that allows it.

8. The open questions, collected

Tier 1 is blocked by none of them.

  1. Symlinks that leave the project root (inside D2, otherwise decided).
  2. Commit to the DSL-const / general-const split (Q1)?
  3. Restrict actions in const, or sandbox them (Q2)?
  4. If sandboxed: which runtime, is the wasm32 requirement acceptable, and does the workload assumption survive measurement (Q3)?
  5. What may a const do — purity, fuel, and how far allocation is bounded (Q4)?
  6. Is confinement plus the lockfile record enough, or is a grant required (Q5)?
  7. Where does the trust line go, and is isolation declared rather than inferred from origin (Q6)?

Question 5 blocks 2, 3 and 4. It is the one to answer first.

This site is open source. Improve this page.