Skip to content

The Graph

Stability: stable.

The graph is the folder tree. A stage’s position decides when it runs, and nothing else does.

There are no edge keys — nothing named next, goto, on-pass, or on-fail. Nothing invokes a stage by name. A stage runs because the flow reached it. Moving a folder changes the graph; editing frontmatter does not.

A sentinel file names what the folder that holds it is, and sentinel names are in capitals. Four of the sentinels type containers — LOOP.md, CHOOSE.md, PARALLEL.md, and FANOUT.md; the others type a flow or a stage, which are not containers (stage types). FANOUT.md is the container whose contents are not folders: it runs one subflow per item and places what those runs produced, and its own chapter is provisional while the rest of this one is settled.

A sequence is a folder of numbered entries that run in order and holds no sentinel: a flow’s contents, a branch of a PARALLEL, an alternative of a CHOOSE. It is a sequence because of where it sits, not because of a file inside it.

A container holding two sentinels, or a container’s folder holding none where one is required, is a malformed assembly.

Sentinel What the folder is
FLOW.md a flow (flow)
STAGE.md a stage that runs once, in its numbered position (stage)
LOOP.md a stage that repeats what is inside it (loop)
CHOOSE.md a stage that runs one of several alternatives (choose)
PARALLEL.md a stage that runs branches at once (parallel)
FANOUT.md one subflow run for each checked list item (fan-out, provisional)
DESCEND.md a flow whose stages may call it again (descend)

A stage that needs no files of its own is a single markdown file, 01-name.md, and needs no sentinel to say what it is.

A numbered entry runs in sequence: 01-read.md before 02-analyze/ before 03-report.md. Two entries sharing a number is an error.

A named entry does not run in sequence. The branches of a PARALLEL and the alternatives of a CHOOSE are named rather than numbered, because a number would be a lie about them.

The name — the part after the number, when there is one — is the entry’s identity. It appears in the record, and it is the filename the next stage finds its input under.

A container has an input, a position, an exit code, and its own frontmatter. It has no hooks, no schema, no gate, and no checklist: those exist to judge an output, and a container produces none of its own. To check what came out of a container, write a stage after it.

A container’s frontmatter sets the same keys a stage’s does — every one of them, retries included, except the stage-only workdir (the stage) — and everything inside it inherits them, so a PARALLEL can put its branches on a cheaper model or a tighter retry budget without repeating the key in each one (invariant 31). It sits between the stage and the flow in the order things resolve (invocation).

A CHOOSE with a body runs an agent of its own, and retries bounds that agent’s send-backs the way it bounds any stage’s. A LOOP with a body runs no agent of its own: its question is put to the agent of its last stage (loop), and the answer round spends that stage’s retries.

A non-zero exit stops the flow whatever the type. What differs between types is only which of their contents run, and how many times.

Sentinel Its key
LOOP.md repeat
PARALLEL.md width
CHOOSE.md none of its own
FANOUT.md items, subflow, width, max-items (fan-out, provisional)

How a container’s work reaches the next stage

Section titled “How a container’s work reaches the next stage”

A container passes along what the stages inside it produced. The next stage’s $INPUT is a directory, so several outputs arrive as several named files and nothing has to be combined (slots).

After a $INPUT holds
STAGE one file, named after the stage
LOOP one file, named after the loop, from the last stage of the last repeat
CHOOSE one file, named after the alternative that ran
PARALLEL one file per branch, each named after its branch
FANOUT one file per item, each named after its item id (fan-out, provisional)

PARALLEL and FANOUT can contribute more than one file. This is why $INPUT is a directory. FANOUT’s rows here carry its own chapter’s provisional label.

The filesystem is the namespace, so three branches producing three formats reach the next stage intact. An author who wants them as one file writes a stage that reads several and writes one, which is an ordinary stage doing an ordinary thing.

Almost nowhere. The graph is the author’s, written in folders, and no agent can change it. The exceptions are the control tools, each a place where the format wants a judgment a model makes better than a path expression: marking a checklist item, refusing or reporting a fault for a stage, ending a loop, selecting an alternative, calling a subflow the author placed in scope, and confirming that this stage’s temporary contents are disposable (control tools). Six of them are the ordinary closed list every stage draws from; subflow is granted separately, only where the author placed a subflow in scope.

Any container holds any container, to any depth, with three exceptions. The third is FANOUT.md, which holds no container and stands in no container: it is a numbered folder in the root sequence of a named entry flow, never first, never last, never nested, and never in a subflow (fan-out). A branch or alternative that is directly a PARALLEL is refused (tail-container): it is one name that would have to carry several outputs, the tail rule below met in another position. A LOOP or a CHOOSE stands anywhere a stage does except last — each passes along one output, but the tail below is a stage’s alone — so a branch of a PARALLEL can be a LOOP, and an alternative of a CHOOSE can be another CHOOSE. And a loop holds no loop (invariant 40): a LOOP inside a LOOP, however many containers sit between them, is refused — loop-nested. Identity has one repeat, and a stage under two loops would need two. Iteration inside iteration is a flow calling a flow: the stage in the outer loop calls a subflow that loops, and each run’s identity stays flat (subflows).

There is no depth limit. What a container does to its contents does not change with depth — it has an input, it runs what is inside it, and it passes along what they produced — so a limit would be a number with nothing behind it.

Every sequence ends in a stage, never in a container.

This is what keeps the tail rules simple. A flow’s output is its last stage’s output, so a flow ending in a PARALLEL would have several. A LOOP’s question is put to the agent of its last stage, so a loop ending in a container would have nobody to ask. An alternative passes along its last stage’s output under the alternative’s name, so an alternative ending in a PARALLEL would have several files and one name.

An author who wants a fan-out at the end writes the stage that reads it, which is the stage that was going to have to exist anyway.

Symbolic links anywhere in an assembly are refused and never followed, so the folder a reader sees is the folder that runs.

Hidden dot-entries are ignored: they name nothing in the graph, they are not hashed, and nothing in them is executed. An assembly kept in git carries its .git directory without the format having an opinion about it. Anything an assembly runs lives where a reader can see it.

README.md is permitted in any folder and is inert: it names nothing in the graph, nothing in it runs, and nothing refuses it — documentation is welcome everywhere. LICENSE is permitted at the assembly root, so an assembly can be a repository somebody publishes.

Stability: stable.

A loop is a stage that repeats what is inside it.

03-refine/
LOOP.md
01-revise.md
02-review/
STAGE.md
schema.json

The folder is typed by LOOP.md and holds numbered stages, which run in order, over and over.

repeat: 10

One key, required on every LOOP.md, and it is always a ceiling: the most times the loop may run. A runtime never invents a bound (invariant 32).

---
repeat: 10
---
Is the draft ready to publish? Another repeat is only worth it if you have
something specific to change.

The body is the question. Writing one is what asks the agent; leaving it out is what does not.

  • No body. The loop runs repeat times and nothing decides anything.
  • A body. The loop runs until the agent says stop, and never more than repeat times.

This is the same rule every sentinel follows: the body of the file is the prompt.

The last stage inside the loop, because it is the only one that has seen the whole repeat.

Its agent stops. The checklist, the schema, and the gate clear it (gating). Only then is it asked the question, and it answers with a control tool (control tools). Asking whether to go around again about work that just failed its gate would be asking about nothing.

The question sits between the checks and the hooks: it is asked after the last check clears and before success runs and the output seals, because until the answer comes back the stage is not over — an agent may refuse or report a fault at the question, and either ends a stage whose checks had already passed (the runtime). The answer round runs inside the stage’s own timeout, like everything else the agent does. An agent that stops without answering the question is held and asked again, spending a retry like any failing round — retries bounds send-backs whatever caused them, and an unanswered question is a round that failed.

The agent is told the question and not how many repeats remain — an agent that knows it is on the last one approves the last one. Which repeat it is on is derivable from its slot paths, and that is accepted; the ceiling is what never leaks.

A loop that reaches repeat while the agent is still saying continue fails, with cause rejected — the assembly’s own bound said no (the record). The instruction was “until it is ready,” and it never got there. Ten unfinished repeats is not a success with the tenth draft.

A loop with no body cannot run out. It ran the number of times it was told to.

repeat is therefore a real budget and not a safety net. A loop asking a question with repeat: 1 fails the run the first time the agent says continue, and one asking with repeat: 3 fails after three clean, gate-cleared repeats.

The loop’s first stage receives the loop’s own input on the first repeat. On every repeat after that it also receives the previous repeat’s output, as another named file in $INPUT (slots):

$INPUT/request.md the loop's input, every repeat
$INPUT/review.json the previous repeat's last stage, from the second on

Every name that can appear there is known before the run starts — after a CHOOSE the loop’s input is one of a known set — so two that could collide are a malformed assembly and the run refuses, naming them.

The stages after the first one inside a loop are ordinary sequential stages: each receives the output of the one before it, repeat after repeat.

The loop passes along the output of its last stage from its final repeat, under the loop’s own name: what leaves 03-refine/ above arrives in the next stage’s $INPUT as refine, with the extension the producing stage’s schema chose. Inside the loop the stages see each other’s names; outside it, which stage wrote the result is the loop’s business (invariant 26).

Each repeat is recorded separately, and every stage inside the loop is identified by which repeat it ran in (the record). A stage that ran four times is four entries, not one.

The decision that ended the loop is recorded with it. ended_by is stop when the agent stops, limit when the authored count ends the loop, or the exact failed-body cause: refused, exhausted, rejected, blocked, timeout, or fault. The event retains the body’s reason. An outside signal writes the signal sequence and no conflicting loop_done.

Stability: stable.

A choose stage picks one of several alternatives and runs only that one.

04-respond/
CHOOSE.md
escalate/
01-page-oncall.md
02-write-incident.md
patch/
01-write-fix.md
revert.md

The alternatives are named, not numbered. They do not run in sequence, so a number would be a lie about them. An alternative is a single stage file, a stage folder, or a folder holding numbered stages that run in order.

An agent. The body of CHOOSE.md is that agent’s prompt, and it lists the alternatives:

---
---
- `escalate` — the change is unsafe and a human has to see it now
- `patch` — the problem is small and mechanical
- `revert` — the change should not have landed

An alternative is named in a code span at the start of a top-level list item. That is the whole of the syntax: everything else in the body is guidance, and a code span anywhere else names nothing.

Every alternative is something to do. A choice is between courses of action, and one of them always runs.

The agent reads the input and names its choice with the select tool (control tools). It has the stage’s $INPUT, $TMP, $SKILLS, $PWD, and $SUBFLOWS when any subflow is in scope, and no $OUTPUT, because a choice is not an output. It may refuse or report a fault, which fails the stage.

An agent that stops without selecting, or that names something that is not an alternative, is told so and asked again in the same session — the same send-back a failing check earns (gating). retries bounds it, and the stage fails when they run out.

In the record the chooser is the choose folder itself, with a retry counter and no output, and its session sits where any stage’s session sits (the record).

There is no other chooser. A deterministic branch — “if the input says critical, escalate” — is a script’s job, and a script belongs in a stage or a gate, not wearing a CHOOSE.md: this element exists for the judgment call a program cannot make. A CHOOSE.md with no body has no chooser and is refused (body-missing). A choose with exactly one alternative is refused (chooser-invalid): a “choice” with one course of action is a sequence wearing the wrong sentinel. A choose folder with no alternatives at all is refused (folder-empty).

Every name listed in the body must be a folder or file in the choose folder, and every folder or file must be listed. A name with nothing to run, or something to run that was never listed, is a malformed assembly and the run refuses before it starts.

The alternative that runs receives the choose stage’s input, unchanged.

The choose stage passes along that alternative’s output, and the next stage receives it named after the alternative (slots):

$INPUT/escalate.json

So a stage after a choice can tell which way the choice went without being told — the filename says it. Whether it does anything with that is the author’s business.

Alternatives may produce different shapes, because they are different things.

The record names what was chosen, what else was available, and the agent’s reason (the record). Alternatives that did not run are named, so a reader can see what the run declined to do.

Stability: stable.

A parallel stage runs several branches at once.

02-assess/
PARALLEL.md
research/
01-gather.md
02-summarize.md
risk/
STAGE.md
schema.json
cost.md

Branches are named, not numbered. They do not run in sequence, so a number would be a lie about them. A branch is a single stage file, a stage folder, or a folder holding numbered stages that run in order — risk/ above is a stage folder, typed by its STAGE.md.

PARALLEL.md has one key of its own, width, and no body. Nothing is asked of an agent here.

The parallel stage’s input is handed to each branch unchanged. Branches do not see each other’s inputs, outputs, or scratch. They do share the working tree — $PWD is one tree (slots) — and this specification does not pretend otherwise: branches that would fight over the same files are the author’s to separate.

Each branch writes its own $OUTPUT, and the stage after the parallel stage receives all of them as named files in its $INPUT (slots):

$INPUT/research.txt
$INPUT/risk.json
$INPUT/cost.txt

There is no merge rule because there is no merge. The filesystem is the namespace, the branch name is the key, and three branches producing three different formats is not a problem to be solved — markdown next to JSON next to a PNG all work, because nothing is trying to combine them.

The stage that follows addresses each branch by name. Its prompt is told the names, so it does not have to discover them (prompt construction).

An author who does want one file writes a stage that reads the three and writes one. That is an ordinary stage doing an ordinary thing, and the format needs no opinion about it.

width: 3

How many branches run at once. All of them, unless this says otherwise. When width holds some branches back, branches start in name order (invariant 41): a pool of width slots, the next name starting when a slot frees. Which branches had started by the time one failed is a fact of timing — a fast sibling frees a slot early — so the record says which ran, per branch, rather than the format promising the same set on every runtime. A runtime records how many ran concurrently, and the recorded order of branches never implies the order they finished in.

width is at most 32, authored or defaulted; more is refused (refusals).

The parallel stage fails. Branches already running are allowed to finish first, and their outputs are kept in the run even though nothing downstream reads them (the record).

When more than one branch failed, the exit code is the one from the first failing branch in name order. Name order is chosen because it is the same before the run as after it: completion order would make the exit code depend on timing.

They are not cancelled because they are working in a shared tree ($PWD), and an agent killed in the middle of a tool call leaves that tree in a state nobody chose. Letting them land is cheaper than reasoning about what half a branch did.

Branches that had not started do not start.

Stability: provisional.

This control may change in a later pre-1.0 release.

A fan-out is a container, and the sentinels chapter says so (the sentinels). Every sentence naming FANOUT in a settled chapter carries this chapter’s label, not that one’s. A fan-out runs one authored subflow for every item in a checked JSON list. Bot derives the complete work set from the immediately preceding stage. The producing agent does not call the subflow itself.

FANOUT.md is a numbered folder in the root sequence of a named entry flow. One ordinary JSON stage must precede it. One ordinary stage must follow it. A fan-out cannot be first, last, nested, or placed in a subflow. Its folder holds only FANOUT.md and optional README.md.

items: jobs
subflow: worker
width: 2
max-items: 16

The sentinel has no body and exactly these four keys. items names a top-level array in the preceding JSON object. subflow names an in-scope subflow whose final node is an ordinary stage. width limits concurrent children. The bounds satisfy 1 <= width <= max-items <= 32.

Each array entry has exactly id and input. An id matches [a-z0-9][a-z0-9_-]{0,127} and is unique. An input is a JSON object. Bot refuses an empty list, malformed JSON or UTF-8, a document above 1 MiB, or any invalid entry before it starts a child.

Bot sorts items by the UTF-8 bytes of their ids and invokes the selected subflow once per item. Each child request is compact JSON for {id,input}. At most width children run at once. An ordinary child failure or machinery fault does not stop siblings. An outside signal stops new launches and settles started children.

Fan-out succeeds only when every child succeeds with one sealed output from the selected subflow’s final root stage. Bot opens each accepted output once and fixes its descriptor and initial size. The next stage receives that held snapshot as one <id>.<extension> input for every item. A path replacement cannot redirect the copy, an append is not chased, and a partial or hash-disagreeing copy is removed before the successor starts. The output has no inspection-size limit. A failed fan-out never passes a partial set. Resume runs the whole fan-out again from the retained predecessor output.

The parent record binds the retained manifest and sorted plan in fanout_start, records one sorted subflow_call disposition per item with via: "fanout", then writes fanout_done. Child records keep the existing subflow layout. Completion timing never controls row order or aggregate failure selection. The record keeps shape 1 because the new events and fields are additive.

Stability: stable.

A flow that may call itself.

DESCEND.md stands in the place of FLOW.md as a flow folder’s sentinel. The flow is otherwise ordinary — numbered stages, the same frontmatter grammar, a required description — and it may sit in flows/ or in any subflows/ scope, invoked or called like any flow (subflow).

What the sentinel changes is one thing: every stage inside the flow has the flow itself in scope as a subflow. A stage of an ordinary flow can never call the flow it stands in, because a folder cannot contain itself and placement is the only grant. DESCEND.md is the author writing the one grant placement cannot express.

description: Break a document into questions it can answer, and answer them
max-depth: 11

One key of its own, required: an integer from 1 through 11 and the longest unbroken chain of this flow’s self-calls. A run invoked from outside is at depth 1. A stage may call its own flow only while the current depth is below max-depth; at max-depth, the flow is simply not in its own stages’ scope, and the agent is told about the subflows that remain, which may be none.

max-depth bounds this flow’s self-calls and nothing else. Another flow between two invocations restarts the self-chain: this flow reached through a subflow it called stands at depth 1 again. The runtime separately allows at most ten subflow calls in a mixed-flow chain (subflow); that fixed safety ceiling makes 11 the largest useful authored depth: the root is at position 0 and depth 1, then ten child calls reach depth 11.

Decomposition the author cannot foresee. A twenty-page document does not announce how it should be split; a codebase question does not say in advance how many smaller questions it contains. A descend flow reads its input, hands the pieces it chooses to invocations of itself, and composes what comes back — each child a full run of the same flow, with the same checks, one level down (subflow).

The author still owns everything but the split: what the flow does, what its stages are, what gates the work, and how deep it may go. The agent owns only what placement could never say — how this particular input divides.

A self-call is a subflow call and is recorded as one: an event in the parent’s record and a complete child run beneath it, at every level. The depth of each invocation is recorded with it, so a reader can see the shape of the descent without reconstructing it.

Stability: stable.

A subflow is a flow a stage can call. The stage’s agent hands the runtime an input, the flow runs as a complete run of its own, and the answer comes back: onto disk always, into the agent’s context when it is small.

A subflow call is an invocation. The child receives what any run receives — request bytes and a working directory — and returns what any run returns, its flow’s output. No session crosses in either direction (invariant 38). The rest of the world calls this a subagent; here the thing called is a flow, gates and all, which is what makes the answer worth trusting.

An assembly can keep an oracle/ subflow — one stage on an expensive model, high reasoning — and any stage with it in scope hands it the hard question and keeps working on its own.

A subflows/ directory may sit at three places, and its position decides who can call what is inside it — the same shape as skills.

Where Callable by
the assembly root every stage
a flow folder the stages of that flow
a stage folder that stage alone

Each entry in a subflows/ directory is a flow: a folder typed by FLOW.md, or by DESCEND.md when it may call itself (descend). A subflow belongs at the narrowest scope where it is useful, like a skill.

A subflow is a flow folder, so it may carry its own skills/ and subflows/, resolved narrowest-first like every other scope.

The scopes are flattened into one list, and names collide by overriding, narrowest first — one name is one subflow. The agent is told each subflow’s name and its description, and nothing about where it came from.

A subflow is not an entry point. Invocation resolves names in flows/ and nowhere else (invocation), so the only way to reach a subflow is to be a stage with it in scope. The reverse holds too: a stage can never call a flow, only a subflow. The one agent with both in scope is the assembly agent, which is what runs when a run names no flow (running the assembly).

A stage with any subflow in scope has the subflow tool (control tools). One call submits a batch of one or more requests, and a batch of one is just a call:

{
"calls": [
{ "flow": "oracle", "input": "Is this migration safe to run twice?" },
{ "flow": "summarize", "input-file": "$TMP/page-03.md" }
]
}

Each entry names a flow in scope and gives the input as text or as a path to a file whose bytes become the input — which is how an agent chains one subflow’s output into another’s input without carrying the content through its own context. The input is the child run’s request, delivered the way any run’s request is delivered (invocation).

A batch holds at most 32 calls; a larger one comes back as a tool result to split, not as a failure.

A tool payload is JSON, and no shell expands it, so the runtime does: any slot variable in an input-file path — $TMP, $SUBFLOWS, $SKILLS, $INPUT, $OUTPUT, $PWD, a declared slot — is expanded by the runtime before the path is read, uniformly, which is what lets an agent name files by slot in a tool call the way it does everywhere else.

The runtime runs the batch’s children at once and the tool returns when the last of them ends. Each child is a run: fresh sessions, its own stages and checks and clocks, and the parent’s $PWD as its inherited default. How many children an agent starts is its own business; a call chain has a fixed ceiling of ten subflow calls, and a child’s time is never on the parent’s clock (invariant 22).

What a child inherits is what invariant 38 allows across the boundary and nothing more: the input as its request, the parent’s $PWD, and the assembly’s declared slot values — a child of the same assembly cannot run without them. It does not inherit the invocation: the parent run’s command-line and task-file overrides are rungs of that invocation, and the child resolves its options from its own sentinels, the home, and the defaults. A child stage that explicitly sets workdir resolves that path from the root workspace selected by the original bot run start --in; omission is what preserves the parent’s effective $PWD.

Every call whose input crosses the subflow boundary lands in the $SUBFLOWS slot, numbered in the order the calls were made, across batches, starting at 1:

$SUBFLOWS/
1/
input.txt
output.md
2/
input.json
output.json

Each call’s folder holds exactly what crossed the boundary, as a typed pair. input carries the extension of what was sent: .txt for inline text, the file’s own extension for an input-file. A file extension must contain only ASCII letters and digits and fit the retained request.<extension> directory entry. Bot refuses another suffix before it creates a child. output carries the extension the child’s final stage’s schema chose. Both names are constant whatever flow ran — composing over calls never depends on what a flow was called; which flow it was is in the tool result and the record. The agent composes over these files by path and never learns where the child’s run record lives.

Bot rejects a flow outside the caller’s resolved scope before it expands or reads an input-file. That disposition keeps its call number but creates no folder. Inline text already exists at the boundary, so its refusal retains the text descriptor in the parent record without creating a folder.

The tool result reports, for each call: its number, its flow, how it ended, the output’s path under $SUBFLOWS, and its size in bytes and lines. The content itself is injected when it is small and pointed at when it is not:

  • At most 10,000 bytes: the whole output, inline.
  • Larger: a marked prefix that, including its marker, is at most 10,000 bytes, and the path to the rest.

The numbers are deliberate limits on what enters the context automatically; the agent reads the rest from $SUBFLOWS when it wants it.

A child that fails or refuses does not fail the parent. The tool result carries how it ended — the same cause vocabulary as any run (the record) — and the reason, in the child’s own words when it refused. The call’s folder keeps the input and no output. The parent read an answer that says no, and what it does about that is what it is for.

Child answers are evidence for the parent, never the parent’s output. The stage still writes its own $OUTPUT, and its own checklist, schema, and gate judge only that (invariant 24). An agent that wants a child’s answer to be its output copies it there and stands behind it.

Every call is one event in the parent’s record: the flow, any admitted input, and how it ended. A child that starts is recorded under the parent stage’s attempt, with its own record and sessions, in the ordinary record shape, read through its parent (the record, inspection). An inline request records its text, size, and hash. When a child starts from an input-file, the request records the normalized retained child request path, size, and hash. It never records the expanded absolute source path. A child record-writer failure leaves that child record visibly incomplete; the parent call keeps the normalized input descriptor and says its machinery failed without inventing an exit or cause.

On disk that is a run directory in the ordinary shape, nested where the call was made — stages/<stage>/<repeat>/<attempt>/subflows/<n>/, the number matching the call’s folder under $SUBFLOWS. A child’s scratch is its own directory under the calling run’s scratch root, named nothing a reader could guess, so it leaves with the run when the run’s scratch does. A child is evidence of its parent’s work, so it lives inside the parent’s run and travels with it when the run is moved or archived; bot run list lists top-level runs only, and a child is reached through its parent.

Placement is a tree, and a folder cannot contain itself, so placement nests only as deep as an author physically wrote. Call chains are not placement: assembly-root subflows are in every stage’s scope, a stage inside one subflow can call another, and two subflows can call each other by turns. The runtime allows at most ten subflow calls in one chain. A root run starts at cumulative position 0, each child advances it by one, and a stage at position 10 has no subflows in scope. Every call is in the record; its depth is the greater of its self-chain depth and cumulative position.

A flow is removed from its own stages’ scope before anything else is added to it, whether it was placed at the assembly root or beside a stage. The rule is the runtime’s, not a consequence of where the folder sits: without the marker below, a flow named review is not in the scope of the stages inside review.

The one thing placement cannot express is a flow seeing itself in its own scope, and that takes the one marker written for it (descend) — whose max-depth bounds that self-chain. The fixed runtime ceiling bounds mixed-flow chains separately.