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)
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.
Compile-time I/O is specified in three places and built in none.
dsl G from <expr> exists and lowers to a runtime
call. Only the runtime half exists.const does not exist at item level, so the discriminator it rests on
has no representation.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.
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.
.. 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.
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.
Not “we will build a regex compiler”, but: the architecture must not preclude one. Already satisfied, by two mechanisms both in use:
std provides — specialised code lives as
hand-written Rust in std and the emitter emits the call. The emitter
contains no target_feature and no intrinsics, and on this route never needs
them.grammar already
does.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.
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.
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:
const is a different mechanism from general const. A DSL
evaluation is closed; a general compile-time call is not — it can name the
program’s own types and functions. Treating them as two lets the closed case
ship first.Open: whether to commit to that split.
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.
| 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.
const?The largest open question, and the one that must be answered before Q2 or Q3, because it decides what the evaluator must support.
const call anything?const fn has no heap, and that
is why Rust falls back to proc-macros — worth naming precisely, because the
usual telling gets it wrong: a proc-macro may allocate freely. The real pain
is that it is a separate mechanism, type-blind because it runs before type
checking, and slow because it is a second compilation. Not impossibility; a
seam.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.
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.
build.rs
attack surface — the most-criticised security property of Rust and the
standard route for supply-chain attacks against it. Adopting it deliberately
is legitimate; adopting it by accident is not. The boundary that matters is
has a human reviewed this, which does not follow from where a file sits.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.
Both poles of Q2 and Q4 are occupied by working languages, which makes this a choice rather than an excuse.
comptime cannot do I/O at all; reading a file is a
separate declarative capability where the compiler resolves the path. That
is D1 plus Q2’s restrict branch, shipped. Its known cost is §3.2’s warning.const fn restricted and heapless;
proc-macros and build.rs unrestricted, native, outside the type checker. A
useful datum: the Tier 1 application everyone cites, perfect hashing, is
served in Rust by a library rather than a language feature.unsafe cast over memory the untrusted side laid
out is not a sandbox), and breaks determinism, since anything containing a
pointer varies with the allocation address. Its legitimate core — do not hand
back text that has to be re-parsed — is met by Q1’s layout instead.grammar path. An axis, not an open question.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.
Tier 1 is blocked by none of them.
const / general-const split (Q1)?const, or sandbox them (Q2)?wasm32 requirement acceptable, and does
the workload assumption survive measurement (Q3)?const do — purity, fuel, and how far allocation is bounded (Q4)?Question 5 blocks 2, 3 and 4. It is the one to answer first.