ADR-100: The dry run at the executor seam
Status: Accepted (2026-09-16, lot 4 of #1010, PR #1292) Relates to: ADR-098 (the authoring surface), ADR-099 (contracts), the framing plan's dispositions F11, F12, F13
Context
A .bot that compiles still dies at its first paid run, in ways the compiler cannot see: a compute that divides into an int, a command: the shell refuses, a {{outputs.x.y}} of a node that has not run on this path, a loop spent with no exit. This is the runtime class of failure — class R in the authoring programme — and it is the most expensive one: a sandbox, a clone and a phase of planning are paid before the death.
The programme promised a way to meet those deaths before the run. The plan's adversarial review settled three things about that promise before a line was written: the e2e scenario executor is not a validator and must not be exposed as one (F11); shapes and two passes are not coverage, so the promise is "reduces R", never "kills R" (F12); and a tool's command: runs under bash -c while a script: runs the interpreter its language: names, so a syntax check must hold text to the interpreter that will read it (F13).
Decision
- The dry run is an implementation of
runtime.NodeExecutor(pkg/dryrun), never a mode of the engine. It renders what a node would send — prompts,command:,script:,postcondition:— through the production renderers (model.TemplateResolver,RenderCommand,RenderScript), holds shell text to its interpreter's parser, and answers with a fixture or a shape of the node's output schema. Nothing reaches a model, a shell or the workspace; the store and the working directory are temporary directories of the run's own. - Every short-circuit the engine needs is a named option (
runtime.WithSimulation(Simulation{AnswerHumans, EventsArrive, AnswersArrive, BranchesRunToTheirEnd, Invented})), read at one site each, and a sweep test proves no production package passes it. There is noif dryrunin the engine. (Amended 2026-09-19:Inventedis the dry run's knowledge of the values it made up — the engine hands it every expression failure, and the dry run answers whether the failure rests on one of them.) - Two passes, bias true then false, so every
whenis met on both sides; the report names the nodes and edges no pass reached, the nodes whose output was only a shape, and each pass's end — a death, or afailnode the bot declares, read apart. A child asubbothands work to is read within the bundle's collection and simulated the same way; its death is the parent's. - The renderer that leaves a hole is the one that names it. An unresolved reference is reported by the resolver at the point it gives up, through a listener; the rendered text is never re-read for braces, because a value may carry
{{…}}of its own and that is the value. - The report never decides
valid.validate --execruns only on a program that compiles, and its findings say what the first paid run would have met;cleanis the report's verdict; the exit code is the compiler's — or, when a dry run was asked for and could not run, the dry run's own inability, said inexec_error— and--strictmakesfailingthe exit code as well, the switch a CI gate flips on purpose. (Amended 2026-09-19, #1318/#1456/#1325: ajsonfield has no defined shape and the dry run guesses none — the object shape, a one-element list only where an iteration reads it. An expression that fails on a value the dry run invented isinconclusive, reported under its own heading, read bycleanbut not byfailing; a failure on the program's own value stays a death. Three guessed shapes in a row had each contradicted another consumer of the same field.) - Alongside, the compiler names the two deaths it can see — C145, a bounded loop with no exit at its cap; C146, a division into an
intfield outsidefloor()/round()— and the runtime names the loop death asLOOP_EXHAUSTED, the code the documentation always promised.fmtandfixclose the loop on the text: canonical form and mechanical remedies, each proven the same program before it is written, each refusing by name what it cannot rewrite.
What is not promised
- Coverage. A shape is not a value: a condition read from a shaped output decided nothing about the real bot, and the report says so by node. Fixtures narrow that, they do not close it.
- The image. A binary a
command:names is not looked for in the sandbox image; a connector action is not executed; alanguage:without a checker is said unchecked. Separating syntax from binaries from arguments (F13) is what makes the first honest. - A fan-out's admission, where the launch is not fully specified. The engine's parallel-branch guard asks the executor where each node runs, and the dry run answers it from the program's values and the caller's
--var. A var the program declares with neither has no value before the launch, so a routing field that launch could still move — a templatedbackend:, awhen:gate on afallbacks:route — is read as its author wrote it: the pessimistic side, which refuses rather than admits. The refusal names those vars and the--varthat decides them; supply them and the dry run answers what the run answers. A child asubbothands work to is launched by itswith:mappings, not the parent's--var: the dry run reads that hand-off as far as the compiled program states it — a literal verbatim, a lone{{vars.x}}reference as the parent's value of x — and every other mapping leaves the child's var undecided, so the same pessimistic side stands there. Which vars a field READS decides nothing — the dry run parses no expression shape to admit a fan-out (#1778). - The registry. A
bot://child is not simulated; its output is a shape, said as such. - Unbounded loops. Their fuel is their ceiling; C098 names the missing convergence condition, and the dry run runs them to the cap like the engine does.
Alternatives rejected
iterion run --stub. A second entry into the engine with a second reading of "simulation": one surface (validate --exec), one executor.- Exposing the e2e scenario executor. Built to script a scenario, not to read a bot; it answers what a test tells it to, which is the opposite of a validator (F11).
- Re-scanning rendered text for
{{. Reintroduces, one layer up, the cascade the renderer's single pass exists to prevent; a fixture carrying braces became a phantom finding in the first round. - Making the dry run's findings fail validation. The compiler's verdict is about the program; the dry run's is about a run that did not happen. Mixing them would make a shaped
whenan error.
Consequences
SKILL.md and docs/dsl.md teach the three steps — validate, validate --exec, run — with fmt and fix around them. The catalogue's ten bounded loops with no exit at their cap are named by C145 and left to their authors (#1293). The retry policy classifies LOOP_EXHAUSTED as deterministic; a run that dies of it is not retried by the fleet.
