executable.md

Control flow

<If> chooses between two branches, <Switch> chooses among several, and <Loop> repeats a region a bounded number of times. All of them are directives the expansion engine handles directly, like <Each> and <Let> — there is no If.md or Loop.md to import.

Choosing a branch

condition is the only prop, and the value it resolves to selects a branch by ordinary JavaScript truthiness. false, 0, -0, 0n, NaN, "", null, and undefined take the false branch; everything else takes the true one — including "false", "0", [], and {}, which are JavaScript's familiar edges rather than a rule <If> invents. So a document can branch on the value it already has, as in <If condition={review.note}>. The optional <Else> block holds the alternative and is written once, as a direct child.

A misspelled member resolves to undefined and quietly takes the false branch; an undeclared identifier is still an error.

<If condition={hasFailures}>
## Test failures

<FailureReport />
<Else>
All checks passed.
</Else>
</If>

Without <Else>, a false condition renders nothing.

<If condition={releaseChanged}>
> [!WARNING]
> Release configuration changed — update the release spec.
</If>

An <If> has exactly two branches, so <Else> is its final substantive child. Blank lines between </Else> and </If> are formatting and are ignored, but any content there belongs to neither branch and is an error rather than a third region quietly attached to the true branch.

<If condition={hasFailures}>
Failures found.
<Else>
All checks passed.
</Else>
This line is an error — it belongs to neither branch.
</If>

Only the selected branch expands

The branch that is not selected is not hidden output — it never expands. Nothing in it imports a component, runs an exec or eval block, reaches an LLM provider, touches the filesystem, creates a binding, or writes a journal entry. Putting expensive work in a branch costs nothing when the condition does not select it.

Nesting

Nested <If> blocks select independently, and each one owns the <Else> beneath it.

<Review as="review" />

<If condition={review.passed}>
Review passed: {review.summary}
<Else>
<If condition={review.blocking}>
Blocked: {review.summary}
<Else>
Needs revision: {review.summary}
</Else>
</If>
</Else>
</If>

Bindings survive the branch

<If> creates no binding scope. A <Let> the selected branch makes behaves like inline content and stays available after </If>, so both branches can bind the same name and later content reads it without knowing which one ran. The unselected branch binds nothing at all.

<If condition={verdict.passed}>
<Let as="headline">All {verdict.total} checks passed.</Let>
<Else>
<Let as="headline">{verdict.failed} of {verdict.total} checks failed.</Let>
</Else>
</If>

<GitHubComment body={headline} />

Choosing among several answers

When a value has three or four answers rather than two, <Switch> states them beside each other instead of asking a reader to reconstruct them from a chain of negated conditions. The selector is evaluated once, each <Case> is compared to it in the order you wrote them, and the first one that matches expands.

<Compare left={installed} right={latest} as="comparison" />

<Switch value={comparison}>
<Case value="newer">
Installing {latest}.

<Install version={latest} />
</Case>
<Case value="current">
{installed} is already the latest release.
</Case>
<Case value="older">
{installed} is newer than {latest}. Downgrading needs `--allow-downgrade`.
</Case>
<Case default>
<Fail message="The release comparison is not recognized." />
</Case>
</Switch>

Matching is JavaScript's === — the same comparison you would write by hand. So "1" does not match 1, two objects match only when they are the same object, and NaN matches nothing, itself included. Comparison stops at the first match, so a case written after it is never evaluated.

<Case default> is the fallback and is written last. It expands only after every other case has been compared and missed. It is optional: a switch with no matching case and no default renders nothing and reports no error, which is what you want when a state simply has nothing to say. Leave it out when the states are closed and an unexpected one should not quietly acquire a branch's behavior.

Only the selected case does work. No other case expands, so nothing in one imports a component, runs a block, reaches a provider, touches the filesystem, or creates a binding — the same guarantee an unselected <If> branch gives.

The whole structure is checked before anything is evaluated. A <Case> that names both a value and default, a stray <Case> outside its switch, or a default written before another case is an error — and it stops the switch, so the selector never runs either.

Like <If>, <Switch> creates no binding scope. The selected case expands inline, so a <Let> inside it stays available after </Switch> and every case can bind the same name. Switches nest, and each one owns the cases written directly inside it.

<Switch value={verdict.state}>
<Case value="passed">
<Let as="headline">All {verdict.total} checks passed.</Let>
</Case>
<Case value="failed">
<Let as="headline">{verdict.failed} of {verdict.total} checks failed.</Let>
</Case>
</Switch>

<GitHubComment body={headline} />

Binding a name with Let

<Let> binds one name in the environment it is written in, and renders nothing where it stands. Written with children it binds what those children render, trailing whitespace trimmed:

<Let as="summary">
{passed} of {total} checks passed.
</Let>

Written with value it binds that value itself — the object, not a rendering of it — so structured data reaches a later prop or eval block without a round trip through text. A <Let> has exactly one source: children and value together are refused, and refused before either one runs.

<Let
  as="releaseSchema"
  value={{
    type: "object",
    required: ["bump"],
    properties: { bump: { enum: ["patch", "minor", "major"] } }
  }}
/>

<Sample schema={releaseSchema} as="decision" />

Repeating with Loop

<Loop> expands a region more than once, under a bound the document states. max is required and must be a positive integer — there is no unbounded form.

<Loop max={3}>
<Probe as="reading" />

Reading {reading.label}: {reading.value}
</Loop>

Bindings carry across iterations

<Loop> creates no binding scope. Each iteration expands in the enclosing environment, so it reads what earlier iterations bound, and the final values stay available after </Loop> — that is how a document acts on what the repetition produced. This is the opposite of <Each>, whose per-item binding lives only for the iteration that renders it.

<Let as="log">attempts:</Let>

<Loop max={3}>
<Attempt as="attempt" />
<Let as="log">{log} {attempt.status}</Let>
</Loop>

<GitHubComment body={log} />

Leaving early with Break

<Break /> ends the loop it is written in. It stops the rest of the current iteration — content after it does not expand, so it imports no component, runs no block, and reaches no provider — and skips the iterations that were left. A nested <Loop> handles its own break.

<Loop name="planning" max={5}>
<Plan />
<Review as="verdict" />
<If condition={verdict.passed}>
<Break />
</If>
</Loop>

<If condition={verdict.passed}>
Plan approved: {verdict.summary}
<Else>
Five revisions were not enough — escalating.
</Else>
</If>

Which loop a <Break /> means is decided by where you wrote it. Content you hand to a component is still your text, so a break in it ends your loop — the component finishes rendering, and the break lands when the invocation returns. A <Break /> a component writes in its own body belongs to a loop in that body, never to the loop that invoked the component.

<Loop max={3}>
<Panel title="Attempt">
<Attempt as="attempt" />
<If condition={attempt.ok}>
<Break />
</If>
</Panel>
</Loop>

A malformed <Break /> — one carrying props or content — does no control action at all. It is not an instruction the loop can act on, so it reports and the loop keeps its own course.

Exhaustion is not failure

Reaching max completes the loop normally and produces no printed error. Whether five rejected plans mean the work failed is a decision only the surrounding document can make, so you write it as an <If> on whatever the body bound — as the review loop above does. A retry limit is explicit document policy, never something <Loop> decides on your behalf.

What the journal records

A loop writes its own entries rather than leaving you to reconstruct what it did from whatever the body happened to record. Each iteration gets an entry before it runs, carrying its zero-based identity — so the entry means the iteration was entered, not that its body finished, and an empty iteration is on the record exactly like a busy one. When the loop finishes it writes one terminal entry saying how: exhausted, break, or error — with the number of iterations entered.

loop_iteration  loop:4:iteration:0   { "iteration": 0 }
loop_iteration  loop:4:iteration:1   { "iteration": 1 }
loop_iteration  loop:4:iteration:2   { "iteration": 2 }
loop            loop:4               { "iterations": 3, "outcome": "break" }

That is what separates a loop that broke on its final iteration from one that exhausted the same bound: identical iteration entries, different terminal outcome. The identity is internal — the loop keeps no counter your document can read. It exists so a run can resume into the iteration it stopped in, not so the body can branch on it.

When a run resumes, the terminal entry is validated rather than trusted. An iteration entry names its own number, so replaying it already checks it; a terminal entry names only the loop, so a recorded exhausted would otherwise stand in for a resumed run that actually broke. If the stored outcome or count disagrees with what the resumed run reached, the run stops with a stale-input error instead of continuing under an outcome it never reached.

And a durability failure is never recorded as a loop outcome. If the journal is already wrong about this run — a resource it recorded is gone, or the replay diverged — the loop writes nothing, and the failure you get back is that failure itself, at the operation that hit it, rather than a later mismatch somewhere downstream. Only an ordinary document failure produces the error outcome.

A stale-record printed error names the loop and the outcome the run derived. It never quotes what the journal held: that is external data, and a printed error that reproduced it would carry whatever it contained into your logs and output.

Interrupted runs

A terminal entry means the loop finished. A loop that is interrupted — the run was cancelled, or the process died — has iteration entries and no terminal entry. Nothing is written on the way down on purpose: an entry appended during teardown would land after the iteration entries a resumed run still has to replay, and would break the resume.

The same holds one level up. A run that finishes ends with a root close: ok on success, err for a document failure, which a loop's error outcome precedes. An interrupted run has no root close at all.

Completed    terminal entry, exhausted or break   root close ok
Failed       terminal entry, error                root close err
Interrupted  iteration entries only               no root close

So the journal tells you whether a run finished, and deliberately does not tell you why an unfinished one stopped — a cancellation and a crash leave the same durable state. They mean the same thing to a reader and take the same recovery path: hand the journal to a new run, which replays what completed and executes the rest live. Which one happened is runtime knowledge, not journal state.

NextExec & Eval