Skip to main content
FieldValue
StatusAuthoritative specification
Last verified2026-06-12
Contractformula_compiler >=2.0.0 (deprecated alias: contract = "graph.v2")
Primary implementationinternal/formula, internal/graphv2, internal/dispatch, internal/molecule
User-facing guideUnderstanding Formulas
TutorialFormulas tutorial
This document specifies the formulas v2 contract: the file format a formula author writes, how the v2 compiler turns it into a graph of beads, and what the controller’s control dispatcher does with the compiled graph at runtime. It is self-contained: the authoring surface shared with v1 is specified here in full, and graph-only constructs state their declaration requirement where they appear. v1 and v2 are peer contracts; both are supported. The v1 contract is specified separately in the Formula Specification — v1. v2 is not a strict superset of v1 — section 5 specifies the differences that keep v1 the contract gc converge accepts today, and the tracked container-dependency gap (#3451). The key words “must”, “must not”, “required”, “shall”, “shall not”, “should”, “should not”, and “may” are to be interpreted as normative requirements unless the paragraph is explicitly marked as non-normative.

0. Concept And Data Model

A formula is a TOML file specifying how work should be carried out — its steps, their ordering and dependencies, and the control flow around them. A formula is not the work itself (a bead is a unit of work) nor a grouping of work (a convoy is a graph of related work); it is the reusable method that produces work when applied. Compilation produces a recipe: a flattened, validated list of steps and dependency edges. Instantiation (gc formula cook, gc sling --formula, or the Go API molecule.Cook / molecule.CookOn / molecule.Attach in internal/molecule) materializes the recipe into the bead store. Under the v2 contract the materialized shape is a workflow:
formula (TOML)
  → compiled recipe (flat, topologically ordered)
    → workflow root bead          (type "task", gc.kind = "workflow")
    + step beads                  (independently routable work, blocking deps only)
    + control beads               (controller-owned: check, retry, fanout,
                                   tally, drain, scope-check, workflow-finalize)
Execution responsibility is split by bead kind:
  • The controller executes every control bead. The control dispatcher in internal/dispatch evaluates check and retry budgets, expands fan-outs, tallies votes, scatters drains, enforces scope failure policy, and finalizes the workflow. No agent participates in control execution.
  • Agents execute only plain work beads. Step beads are independently Ready-visible and routable, so different steps of one workflow may be worked by different agents, pools, or providers.
The execution model is the structural difference from v1:
v1Formulas v2
Compiled shapeParent-child molecule tree under a molecule container rootFlat graph: task root plus step beads linked only by blocking dependency edges
Runtime engineNone. Conditions and loops resolve at cook time; afterwards the molecule is inert dataThe controller’s control dispatcher executes every control bead — check and retry evaluation, fan-out, tally, drain, scope checks, workflow-finalize
Who advances workAgents working hooked beads, inside their own sessionsThe controller drives orchestration outside any agent session; agents only run plain work beads
Agent fan-outThe molecule is typically worked by the one agent it is slung to; spreading steps across agents is manual routingStep beads are independently routable; per-step routing intent resolves at dispatch, and drain / on_complete fan out across agents or pools at runtime
Root visibilityThe container root is the molecule’s handleThe root blocks on workflow-finalize and only becomes Ready when the workflow completes (section 2)
A minimal v2 formula:
formula = "pancakes"
description = "Make pancakes from scratch"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "dry"
title = "Mix dry ingredients"
description = "Combine flour, sugar, baking powder, salt in a large bowl."

[[steps]]
id = "wet"
title = "Mix wet ingredients"
description = "Whisk eggs, milk, and melted butter together."

[[steps]]
id = "combine"
title = "Combine wet and dry"
description = "Fold wet ingredients into dry. Do not overmix."
needs = ["dry", "wet"]

[[steps]]
id = "cook"
title = "Cook the pancakes"
description = "Heat griddle to 375F. Pour 1/4 cup batter per pancake."
needs = ["combine"]

[[steps]]
id = "serve"
title = "Serve"
description = "Stack pancakes on a plate with butter and syrup."
needs = ["cook"]

1. File Format

This section specifies the full authoring surface. Every construct here is accepted under the v2 contract. Constructs marked graph-only (check, retry, drain, on_complete, tally, timeout, and reserved gc.* step metadata) additionally require the explicit contract declaration of section 5 — they are rejected without it.

1.1. File Naming And Layers

Formula files live in formulas/ directories:
FilenameStatus
formulas/<name>.tomlCanonical
formulas/<name>.formula.tomlAccepted deprecated spelling; the .formula infix is not part of the formula name
formulas/<name>.formula.jsonLoader-only deprecated fallback; excluded from symlink staging
Formula directories are collected into layers, ordered lowest to highest priority:
LayerDirectory
1formulas/ directories from city packs (imported packs)
2The city’s own formulas/ directory
3formulas/ directories from rig packs
4The rig’s formulas_dir (rig-local override declared on [[rigs]]; relative paths resolve against the city directory)
Resolution must be last-wins across layers: the highest-priority layer containing a formula name wins. Within a single layer, <name>.toml beats <name>.formula.toml, which beats <name>.formula.json. At city start, init, rig add, and supervisor start, the resolver symlinks each winning file into <scope>/.beads/formulas/ under both <name>.toml and the deprecated <name>.formula.toml alias. Real (non-symlink) files already present in .beads/formulas/ are never overwritten. City-level [formulas].dir is not valid configuration. Authoring it is a hard config error:
[formulas].dir is no longer supported; use the well-known formulas/ directory
and the gc doctor check v2-formulas-dir reports any remaining declaration as an error.
Compatibility: Builds predating the shared last-wins resolver (issue #2027, fixed by #2028) resolved gc formula show/cook/sling first-wins, letting imported pack formulas shadow same-name city overrides. If an override does not take effect, inspect gc formula show <name> against the layer you expect to win.

1.2. Top-Level Keys

KeyTypePurpose
formulastringRequired. Unique formula name used by gc formula cook, gc sling --formula, and molecule.Cook/CookOn
descriptionstringHuman-readable description; supports {{var}} substitution
requirestableHost capability requirements. formula_compiler (a semver comparator) is the only axis; unknown axes fail with formula.requirement_unknown (section 5)
contractstringDeprecated v2 opt-in. Only valid value: "graph.v2"; anything else fails validation. Prefer [requires] (section 5)
extends[]stringParent formulas to compose from (section 1.7)
varstableTemplate variable declarations (section 1.4)
steps[]tableWork items to create (section 1.3)
typestringworkflow (default), expansion, or aspect
phasestringv1-era materialization hint: "liquid" (pour) or "vapor" (wisp). phase = "vapor" without pour compiles a root-only recipe — steps are not materialized as beads. Kept for compatibility; not a design surface for new formulas
pourboolMaterialize each step as a bead row (checkpoint recovery). Default false. Monotonic through extends: any ancestor’s pour = true sticks
catalogtable{name, description} opting the formula into workflow-catalog discovery (gc formula catalog)
template[]tableExpansion template steps for type = "expansion" formulas ({target} / {target.description} placeholders)
composetableAdvanced composition rules: bond_points, hooks, expand, map, branch, gate, aspects (section 1.7)
advice[]tableAdvanced before/after/around step transformations applied during cooking
pointcuts[]tableAdvanced target patterns for type = "aspect" formulas
Unknown top-level keys are silently ignored, with one exception: unknown keys inside [requires] are hard errors (section 5).

1.3. Steps

Each [[steps]] entry becomes one bead in the instantiated recipe. Rows marked graph-only require the explicit v2 declaration (section 5); a formula that uses them without it must fail to compile.
KeyTypeDeclarationPurpose
idstringRequired. Unique across the whole formula, including children
titlestringRequired unless expand is set; supports {{var}} substitution
descriptionstringStep instructions shown to the agent; supports {{var}}
description_filestringPath to a file whose contents replace description (section 1.8)
notesstringAdditional notes; supports {{var}}
typestringIssue type: task, bug, feature, epic, chore (conventional vocabulary; not validated)
priorityint0–4; out-of-range values are rejected
tags[]stringLabels applied to the created bead. The TOML key is tags; deprecated JSON formulas spell it labels — a TOML labels key is silently ignored
metadatatableString key/value pairs copied to the cooked bead. gc.* keys are reserved for the runtime; several force the v2 declaration (section 2)
depends_on[]stringStep IDs this step blocks on; must reference known IDs
needs[]stringSimpler alias for depends_on; both are real and merged during cooking
conditionstringCompile-time include/exclude (section 1.5)
children[]stepNested sub-steps; IDs share the formula-wide namespace
assigneestringDefault assignee; supports {{var}}
expandstringInline an expansion formula here (the step is replaced by its template steps)
expand_varstableVariable overrides for the inline expansion
looptableIteration container (section 1.6)
waits_forstringFanout gate: all-children, any-children, or children-of(step-id); the referenced step must exist (sections 2 and 4)
gatetableAsync wait condition {type, id, timeout} (sections 2 and 4)
checktablegraph-onlyInline run/check verification loop (section 3.1)
retrytablegraph-onlyTransient retry loop (section 3.2)
draintablegraph-onlyScatter the input convoy into unit convoys (section 3.3)
on_completetablegraph-onlyRuntime fan-out over step output (section 3.4)
tallytablegraph-onlyAggregate fan-out voter outputs; requires on_complete (section 3.4)
timeoutduration stringgraph-onlyMax duration for this step’s check script; requires check; check.check.timeout takes precedence
Unknown step keys are silently ignored. A typo like dependson produces no diagnostic — the dependency simply vanishes.

1.4. Variables

Declare variables in a top-level [vars] table. Two forms exist: a string shorthand that sets only the default, and a table form with validation.
formula = "deploy"
description = "Deploy {{env}} from {{branch}}"

[requires]
formula_compiler = ">=2.0.0"

[vars]
branch = "main"

[vars.env]
description = "Deployment environment"
required = true
enum = ["dev", "staging", "prod"]

[[steps]]
id = "deploy"
title = "Deploy {{env}}"
Table-form fields:
FieldTypePurpose
descriptionstringWhat the variable is for; shown by gc formula show
defaultstringValue used when none is provided. An explicit empty string is a valid default
requiredboolThe variable must be provided at instantiation. Declaring required = true together with a default fails validation: vars.<name>: cannot have both required:true and default
enum[]stringAllowed values; enforced at instantiation
patternstringRegex the value must match; enforced at instantiation
typestringParsed but not enforced (section 4)
{{key}} placeholders substitute into descriptions, titles, notes, assignee, and metadata values. Values are supplied as key=value pairs at instantiation:
gc sling worker deploy --formula --var env=prod
Reserved names. A v2 formula must not declare vars named convoy_id or bead_id — validation fails with vars.<name>: formulas v2 reserved variable cannot be declared — and callers must not supply them (formulas v2 reserved variable "<name>" cannot be supplied by the caller). issue is tolerated as a deprecated compat alias:
  • {{convoy_id}} — the input convoy of a targeted invocation. References must be spelled exactly {{convoy_id}} (any other spelling fails with convoy_id requires a targeted formulas v2 invocation), and any reference forces a targeted invocation (gc sling --on <formula> or gc formula cook --attach); an untargeted invocation fails with v2 formula "<name>" requires a target convoy. Enforcement happens at invocation time (section 3).
  • {{bead_id}} — forbidden: bead_id is not available in v2 formulas; use convoy_id.
  • {{issue}} — deprecated one-release compat alias resolving to the single tracked member of the input convoy; cook and sling print warnings: deprecated in formulas v2 and removed next release; migrate to the convoy_id work-bead derivation (gastownhall/gascity#2941).

1.5. Conditions

A step condition is a compile-time include/exclude filter evaluated during compilation, before any beads exist. The grammar is:
FormMeaning
{{var}}Include when the value is truthy
!{{var}}Include when the value is falsy
{{var}} == valueInclude on equality (quotes around value are stripped)
{{var}} != valueInclude on inequality
Falsy values are: empty string, false, 0, no, off. Everything else is truthy. Excluded steps are removed from the recipe along with their dependency edges. This grammar applies only to step condition. Loop until conditions use the runtime condition evaluator’s grammar instead (section 1.6).

1.6. Loops

A step with a [steps.loop] table becomes an iteration container that expands its body steps. Exactly one of count, until, or range is required, and body must be non-empty.
ModeKeysExpansion
countcount = NCompile time: the body is expanded N times, with iteration N+1 chained after iteration N
rangerange = "start..end", optional varCompile time: bounds support integers, + - * / ^, parentheses, and {var} substitution; var exposes the iteration value as {var} in body steps
untiluntil = "<condition>", max = N (required)Compile time: one iteration is expanded and its first body step is labeled with the condition and max budget for runtime re-execution — which no current runtime performs (section 4)
A range loop (the same shape works with count):
formula = "hanoi"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "moves"
title = "Tower moves"

[steps.loop]
range = "1..3"
var = "move_num"

[[steps.loop.body]]
id = "move"
title = "Move {move_num}"
gc formula show hanoi renders the chained iterations (the workflow-finalize step comes from the v2 contract — section 2):
Formula: hanoi

Steps (4):
  ├── hanoi.moves.iter1.move: Move 1
  ├── hanoi.moves.iter2.move: Move 2 [needs: hanoi.moves.iter1.move]
  ├── hanoi.moves.iter3.move: Move 3 [needs: hanoi.moves.iter2.move]
  └── hanoi.workflow-finalize: Finalize workflow [needs: hanoi.moves.iter3.move]
An until loop expands a single iteration and records the condition and max budget as a loop: label on the first body step:
formula = "poll-until"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "poll"
title = "Poll for completion"

[steps.loop]
until = "probe.status == 'complete'"
max = 5

[[steps.loop.body]]
id = "probe"
title = "Probe the endpoint"
Formula: poll-until

Steps (2):
  ├── poll-until.poll.iter1.probe: Probe the endpoint
  └── poll-until.workflow-finalize: Finalize workflow [needs: poll-until.poll.iter1.probe]
Two caveats. First, the re-run never happens in the current release: the until label is written but nothing consumes it, so an until loop runs exactly one iteration (section 4). Prefer check (section 3.1) for controller-driven re-execution. Second, until does not use the {{var}} == value step-condition syntax: until = "{{ready}} == yes" fails with unrecognized condition format. The grammar is the runtime condition evaluator’s: probe.status == 'complete', step.output.field == value, children(x).all(status == 'complete'), steps.complete >= 3.

1.7. Composition And Inheritance

extends composes a child formula from one or more parents:
  • Child steps replace parent steps with the same ID whole-step (no field-level merge), preserving the parent’s position; new child steps append.
  • Parent vars are inherited; child declarations override.
  • phase is taken from the child, else the first parent that declares one.
  • pour is monotonic: any ancestor’s pour = true sticks; a child cannot opt out.
  • contract and requires come from the child, else the first parent — and requirement constraints from every parent are still collected and validated as a set (section 5).
  • Circular extends chains must fail (circular extends detected: a -> b -> a).
Expansion formulas (type = "expansion") declare template steps with {target} / {target.description} placeholders; a step’s expand key inlines an expansion formula in place of the step, with expand_vars overriding variables for that expansion.
The formula resolved through extends drops advice and pointcuts entirely — including the child’s own. And when both parent and child declare compose rules, the merge keeps only bond_points, hooks, expand, and map; branch, gate, and aspects rules from both sides are dropped. Do not rely on full inheritance of composition rules.

1.8. Description Files

Use description_file when a step’s instructions should live in a separate Markdown file instead of inline TOML. Non-asset paths resolve relative to the formula file. Paths using ../assets/... resolve through the same low-to-high formula layer order as the formula itself (section 1.1). A bundled formula can reference:
description_file = "../assets/workflows/review/local-review.md"
and a city or higher-priority pack can override only that prose by providing the same asset path next to its formula layer. The formula structure and step IDs remain inherited from the lower-priority pack; only the description file content is shadowed. Other relative or absolute paths that happen to contain an assets segment still use normal formula-relative or absolute resolution. Description file reads use the same configured source as formula reads, so a parser pinned to a git ref also reads committed description file content from that ref. Two behaviors are normative:
  • Files larger than 4096 bytes are not inlined. The step’s description is replaced by a generated pointer that directs the agent to read the file at its resolved path.
  • A v2 formula must fail fast on an unresolvable description_file. (v1 formulas skip it best-effort and keep any inline description.)

1.9. Validation

A formula must satisfy these structural rules; violating any is a validation error:
  • formula name is required.
  • contract, if set, must be "graph.v2" (contract: invalid value "<value>" (must be graph.v2)).
  • type must be workflow, expansion, or aspect.
  • A var must not combine required = true with a default.
  • Step id is required and globally unique, including children.
  • Step title is required unless expand is set.
  • priority must be 0–4.
  • depends_on / needs entries must reference known step IDs (including children).
  • waits_for must match all-children, any-children, or children-of(step-id) with the referenced step present (waits_for has invalid value "<value>" (must be all-children, any-children, or children-of(step-id))).
  • on_complete: for_each and bond must be set together; for_each must start with output.; parallel and sequential are mutually exclusive.
  • tally requires on_complete; mode must be majority, unanimous, or any-pass.
  • check: max_attempts ≥ 1; the inner check table is required with mode = "exec" (the only supported checker) and a non-empty path. Unexpected keys fail: step.check: unsupported key "<key>" (expected max_attempts or check) / step.check.check: unsupported key "<key>" (expected mode, path, or timeout).
  • retry: max_attempts ≥ 1; on_exhausted must be hard_fail or soft_fail.
  • drain: see section 3.3 for field constraints and incompatibilities.
  • timeout must be a positive Go duration and requires check.
  • Loops (validated during control-flow expansion): body non-empty; exactly one of count / until / range; max required with until.
Construct-combination restrictions are specified with each construct in section 3.

2. Compilation

Compilation is a fixed pipeline: load → resolve extends → reserved-symbol validation → control-flow expansion (loops, branches, gates) → advice → inline expansion → compose expand/map → aspects → condition filtering → standalone expansion → requirement merge and the explicit-declaration check → retry transform → check transform → host requirement validation → graph validation → graph control injection → recipe. Requirement constraints contributed by composed expansion and aspect formulas are merged before validation (section 5). The v2 compiler must emit a flat, topologically ordered graph:
  • Blocking dependency edges only. Step beads carry blocks edges from needs / depends_on (and readiness-blocking waits-for edges from waits_for). The compiler creates no parent-child edges between graph steps; nesting in children affects ID namespacing and validation, not runtime hierarchy.
  • workflow-finalize is appended. A control step with ID workflow-finalize (kind workflow-finalize) is added depending on every sink step, so it becomes Ready exactly when all other work is terminal.
  • The root blocks on the finalize step. The workflow root bead is made to depend on workflow-finalize (or, when a recipe has no finalize step, on every step whose gc.kind is not one of the generated run, check, retry-run, retry-eval, or spec kinds). Consequence: the root is never Ready-visible while the workflow runs and only surfaces when the workflow completes. Step beads — not the root — are the Ready-visible work that wakes agents and pools.
  • Non-blocking tracks edges to the root. Batch instantiation connects every non-root node to the root with a tracks edge so cascade deletion from the root discovers all workflow beads without making the root a readiness blocker.
  • Step beads keep routable types. Non-root graph step beads are not coerced to scaffolding types, so Ready() surfaces them for worker claim and each step is independently routable.
  • Cycles are rejected: v2 formula "<name>" contains a dependency cycle.
Root stamping. The recipe root is type task with gc.kind = "workflow" plus a gc.formula_contract marker recording the contract identifier (the same literal value accepted by the deprecated contract key). Sling additionally stamps the root with gc.input_convoy_id (targeted invocations), gc.graphv2_root_key (an idempotency key deduplicating repeat instantiations of the same workflow), and gc.graphv2_vars.v1 (runtime variable snapshot). Non-batch instantiation stamps gc.formula_hash (SHA-256 of the raw formula file bytes) and gc.formula_source on the root; gc formula version-check <bead-id> compares the stored hash against the on-disk recipe to detect formula drift since spawn. gc.kind vocabulary. The compiler and dispatcher reserve these values of the gc.kind step-metadata key (all hyphenated):
GroupValuesAuthor may set
Control kinds (dispatched by the controller)retry, ralph, check, retry-eval, fanout, tally, drain, scope-check, workflow-finalizeNo — compiler/controller-owned; authored values are not validated and produce unspecified dispatcher behavior
Structural kinds (compiled into graphs, never dispatched)scope, cleanup, run, retry-runscope and cleanup only (section 3.5)
Root kindsworkflow, wispNo — stamped by the compiler
SidecarspecNo — generated step-spec sidecars
A drain step must not set gc.kind at all (section 3.3). Authoring any of the reserved kind values, or any of the scope keys gc.scope_name, gc.scope_role, gc.scope_ref, gc.continuation_group, gc.on_fail, forces the explicit v2 declaration (section 5). The compiler itself writes gc.on_fail = "abort_scope" on check body members and nested retry children (section 3.5). Dispatch routing intent. A step’s gc.run_target metadata is compile-time routing intent: at dispatch the router resolves it into gc.routed_to, the sole persisted routing key, overriding the convoy-wide default for that step. Per-dispatch provider options ride opt_* step metadata (for example opt_model), validated against the provider’s options schema at spawn; gc.model is a deprecated spelling that the gc doctor check work-option-metadata-migration migrates to opt_model. Gates and waits_for. A [steps.gate] table synthesizes a sibling gate bead (type gate, title Gate: <type> <id>) and a blocks edge from the gated step to it: the step stays blocked until the gate bead is closed. waits_for records a gate:<value> label on the cooked step bead and compiles a waits-for dependency edge on the spawner step, treated as readiness-blocking. The gate type vocabulary and the waits_for mode distinction have no runtime consumer (section 4). Preview. gc formula show <name> renders the compiled recipe. For the minimal formula of section 0 (five authored steps render as six):
Formula: pancakes
Description: Make pancakes from scratch

Steps (6):
  ├── pancakes.dry: Mix dry ingredients
  ├── pancakes.wet: Mix wet ingredients
  ├── pancakes.combine: Combine wet and dry [needs: pancakes.dry, pancakes.wet]
  ├── pancakes.cook: Cook the pancakes [needs: pancakes.combine]
  ├── pancakes.serve: Serve [needs: pancakes.cook]
  └── pancakes.workflow-finalize: Finalize workflow [needs: pancakes.serve]
gc formula cook pancakes materializes the recipe; every step gets an independent bead ID (not a child-suffix of the root), and the created count is steps + finalize + root:
Root: mc-79s
Created: 7
pancakes -> mc-79s
pancakes.combine -> mc-265
pancakes.cook -> mc-nia
pancakes.dry -> mc-b8g
pancakes.serve -> mc-k3q
pancakes.wet -> mc-0ez
pancakes.workflow-finalize -> mc-9vb
gc sling <target> <formula> --formula starts the workflow and routes the step graph:
Started workflow mc-btj (formula "pancakes") → mayor

3. Runtime

Invocation. gc sling --formula / --on and gc formula cook / --attach normalize a v2 invocation before instantiation. A formula that references {{convoy_id}} (or the deprecated {{issue}}) or contains a drain step requires a target convoy; an untargeted invocation fails with v2 formula "<name>" requires a target convoy. Targeted invocations inject convoy_id, resolve the deprecated issue alias to the single tracked convoy member, and stamp the root as specified in section 2. The reserved-variable rules of section 1.4 are enforced at this point. Control dispatch. The controller’s control dispatcher processes every open control bead by gc.kind: retry, ralph, check, retry-eval, fanout, tally, drain, scope-check, and workflow-finalize. An unknown control kind is a hard dispatcher error. Structural kinds (scope, cleanup, run, retry-run) are never dispatched. Control execution requires only the controller; no user-configured agent role participates.

3.1. Check

[steps.check] wraps a step in an inline run/check verification loop: after each iteration closes, the controller runs the configured script; pass closes the step, fail with budget left spawns the next iteration, exhaustion closes the step as failed.
KeyPurpose
max_attemptsTotal run/check attempts including the first (≥ 1)
check.modeChecker implementation; only "exec" is supported
check.pathRepo-relative or absolute script path to execute
check.timeoutScript execution bound (e.g. "2m"); takes precedence over the step’s timeout
formula = "checked"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "implement"
title = "Implement the feature"

[steps.check]
max_attempts = 3

[steps.check.check]
mode = "exec"
path = "scripts/verify.sh"
timeout = "2m"
Materialization: the compiler emits a spec sidecar (<step>.spec, kind spec, carrying the serialized step definition), the first iteration (<step>.iteration.1) as the agent-visible work, and keeps the original step ID as the control bead (kind ralph) blocking on the live iteration:
Formula: checked

Steps (4):
  ├── checked.implement.spec: Step spec for Implement the feature (spec)
  ├── checked.implement.iteration.1: Implement the feature
  ├── checked.implement: Implement the feature [needs: checked.implement.iteration.1]
  └── checked.workflow-finalize: Finalize workflow [needs: checked.implement]
The step timeout applies as a general bound on the check script; a check.timeout takes precedence when both are set. A check step with children wraps each iteration in a scope bead whose members default to gc.on_fail = "abort_scope" (section 3.5). check must not be combined with loop, on_complete, gate, expand, assignee, or retry.

3.2. Retry

[steps.retry] wraps a step in a transient-failure retry loop. Where check verifies output with a script, retry re-runs attempts the controller classifies as transient failures.
KeyPurpose
max_attemptsTotal attempts including the first (≥ 1)
on_exhaustedTerminal outcome when the budget is exhausted: hard_fail (default) or soft_fail
formula = "retry-fetch"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "fetch"
title = "Fetch the dataset"

[steps.retry]
max_attempts = 3
on_exhausted = "soft_fail"
Formula: retry-fetch

Steps (4):
  ├── retry-fetch.fetch.spec: Step spec for Fetch the dataset (spec)
  ├── retry-fetch.fetch.attempt.1: Fetch the dataset
  ├── retry-fetch.fetch: Fetch the dataset [needs: retry-fetch.fetch.attempt.1]
  └── retry-fetch.workflow-finalize: Finalize workflow [needs: retry-fetch.fetch]
The control bead keeps the original step ID (kind retry); attempts are <step>.attempt.N. The controller classifies each closed attempt:
  • pass — the control closes gc.outcome = pass, copying the attempt’s gc.output_json and non-gc.* metadata upward.
  • hard — the control closes gc.outcome = fail with gc.final_disposition = hard_fail; no further attempts.
  • transient — with budget left, the controller spawns the next attempt; at max_attempts, exhaustion applies on_exhausted: hard_fail closes the control gc.outcome = fail and gc.final_disposition = hard_fail; soft_fail closes it gc.outcome = pass with gc.final_disposition = soft_fail so downstream work continues with degraded coverage.
retry must not be combined with check, loop, on_complete, gate, expand, or children.

3.3. Drain

[steps.drain] is the canonical v2 fan-out: it scatters the input convoy into one-member unit convoys and runs an item formula per unit. A drain step forces a targeted invocation, and the item formula must itself declare the v2 contract — both at invocation (drain item formula "<item>" for v2 formula "<parent>" must declare the formulas v2 contract ([requires] formula_compiler = ">=2.0.0")) and again at runtime.
KeyPurpose
formulaRequired. Item formula run per unit convoy; {{templated}} names are rejected in v0
contextseparate (default — all item roots created in parallel) or shared (one single-lane item root at a time)
member_accessread (default) or exclusive (records a per-member gc.exclusive_drain_reservation; fails if another drain owns it)
max_unitsCap on expansion: 1–100 in v0; default and hard cap 100. Exceeding the cap closes the drain failed with gc.failure_reason = limit_exceeded
on_item_failureskip_remaining or continue; defaults to continue for separate drains, skip_remaining for shared. A shared skip marks remaining items skipped with reason previous_item_failed
continuation_groupShared execution group suffix; valid only with context = "shared"
item.single_laneMust be true for shared drains
formula = "review-batch"
description = "Run a work formula for each convoy member"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "scatter"
title = "Process every member"

[steps.drain]
formula = "mol-do-work"
context = "separate"
max_units = 20
A drain without the v2 declaration fails validation: <step>.drain: drain steps must declare the formulas v2 contract ([requires] formula_compiler = ">=2.0.0"). drain must not be combined with assignee, expand, gate, loop, on_complete, check, retry, children, timeout, or an authored gc.kind metadata value.

3.4. On-Complete And Tally

[steps.on_complete] is a runtime for-each fan-out over the step’s structured output: when the step completes, a formula is instantiated for each element of a collection in its output. The compiler marks such steps gc.output_json_required and injects a <step>-fanout control step (kind fanout) carrying gc.for_each, gc.bond, the fan-out mode (parallel | sequential), and any bond variable bindings.
KeyPurpose
for_eachPath to the iterable collection in step output; must start with output.
bondFormula instantiated per item. for_each and bond must be set together
varsVariable bindings per iteration; {item}, {item.field}, and {index} placeholders
parallel / sequentialRun bonded work concurrently (default) or one at a time; mutually exclusive
[steps.tally] aggregates the voters spawned by an on_complete fan-out into a single pass/fail outcome. The compiler injects a <step>-tally control (kind tally) after the fanout and rewrites downstream references to wait on the tally result.
KeyPurpose
vote_fieldDot-separated JSON path within each voter’s gc.output_json to read as the vote; empty means use each voter’s raw gc.outcome
modemajority (default), unanimous, or any-pass
Tally semantics: majority passes when the most common vote holds a strict majority (more than half of all votes), failing as no-majority otherwise; unanimous passes only when every vote is identical; any-pass is defined over voter gc.outcome independent of vote_field and passes when any voter passed. Zero voters tally as pass (no-voters).
formula = "vote"
description = "Fan out voters and tally their verdicts"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "survey"
title = "Survey reviewers"

[steps.on_complete]
for_each = "output.reviewers"
bond = "mol-do-work"
parallel = true

[steps.on_complete.vars]
reviewer = "{item.name}"

[steps.tally]
vote_field = "verdict"
mode = "majority"
drain is the graph-native canonical fan-out. Fan-out via authored gc.output_json_required step metadata is deprecated in v2 formulas: gc lint warns gc.output_json is deprecated; use drain in v2 formulas (see: engdocs/drain-fanout.md). The runtime keys themselves remain live — workers write gc.output_json, and the compiler still sets gc.output_json_required for on_complete steps and check output sinks.

3.5. Scopes And Failure Policy

A scope groups steps under a durable scope body with shared failure policy. The scope body is an authored step with gc.kind = "scope", a gc.scope_name, and gc.scope_role = "body"; members reference it with gc.scope_ref = "<body-step-id>" and a gc.scope_role (setup, member, teardown); cleanup steps use gc.kind = "cleanup". The compiler injects a <step>-scope-check control (kind scope-check, role control) for steps carrying a gc.scope_ref.
formula = "scoped"

[requires]
formula_compiler = ">=2.0.0"

[[steps]]
id = "body"
title = "Worktree scope"
needs = ["implement"]
metadata = { "gc.kind" = "scope", "gc.scope_name" = "worktree", "gc.scope_role" = "body" }

[[steps]]
id = "implement"
title = "Implement the change"
metadata = { "gc.scope_ref" = "body", "gc.scope_role" = "member", "gc.on_fail" = "abort_scope" }

[[steps]]
id = "cleanup"
title = "Tear down the worktree"
needs = ["body"]
metadata = { "gc.kind" = "cleanup", "gc.scope_ref" = "body", "gc.scope_role" = "teardown" }
Failure policy. gc.on_fail = "abort_scope" is the only specified value of gc.on_fail. When a scoped member fails, the controller skips all remaining open scope members, propagates non-gc.* member metadata onto the scope body (so diagnostics survive), and closes the body with gc.outcome = fail. The worker-result contract for abort_scope members is fail-closed: a member that closes with gc.outcome = fail counts as failed, and so does a member that declared abort_scope and closed with a missing or unknown gc.outcome — only pass and skipped do not abort the scope. Retry-managed attempt subjects are exempt; their contract violations are classified by retry evaluation as transient retries instead. The compiler writes gc.on_fail = "abort_scope" by default on check body members and nested retry children. Workflow finalize. When workflow-finalize becomes Ready, the controller aggregates the outcomes of its blockers into a single pass/fail, closes the workflow root with that outcome (root first, so a crash retries finalization), closes generated spec sidecars, and — on pass only — propagates closure across the gc.source_bead_id chain. Failures intentionally leave parent source beads open for investigation.

4. Accepted But Inert

This specification is normative for implemented behavior. The constructs in this section are accepted by the parser and compiler but have no runtime consumer in the current release. Authors should not rely on them; they are documented to prevent silent surprise.
  • Until-loop re-execution. Compiling an until loop validates the condition and writes a loop:{"until":...,"max":...} label on the first body step (the loop expander in internal/formula/controlflow.go), but no component — neither the v1 cook path nor the v2 control dispatcher — reads that label. An until loop therefore runs exactly one iteration. Use check (section 3.1) for controller-driven re-execution.
  • Gate type vocabulary. [steps.gate] synthesizes a real gate bead that blocks its step until the gate bead is closed (manually or by an external watcher), but the type values gh:run, gh:pr, timer, human, and mail are doc-comment vocabulary in internal/formula/types.go — the parser never validates them and no bundled watcher acts on them. Zero bundled formulas use gate.
  • waits_for gate modes. waits_for compiles to a readiness-blocking waits-for dependency edge plus a recorded gate mode, but no current dispatcher logic interprets the all-children / any-children distinction. Zero bundled formulas use waits_for.
  • vars.<name>.type. The variable type field (string, int, bool) is parsed into the variable definition (internal/formula/types.go) but never enforced; only required, enum, and pattern are validated at instantiation.

5. Conformance And Compatibility

Opt-in surface

DeclarationWhereStatus
[requires] formula_compiler = ">=2.0.0"formula TOMLCanonical v2 opt-in
contract = "graph.v2"formula TOMLDeprecated opt-in. gc doctor warns: deprecated contract = "graph.v2"; use [requires] formula_compiler = ">=2.0.0"
[daemon] formula_v2city.tomlHost switch, default true. When false the host compiler capability is 1.0.0 and v2 formulas fail to compile
The canonical declaration:
[requires]
formula_compiler = ">=2.0.0"
formula_compiler is the only [requires] axis. The value must be a semver comparator; violations fail with formula.compiler_requirement_invalid: formula_compiler must be a semver comparator, for example ">=2.0.0", and unknown axes fail with formula.requirement_unknown: unknown formula requirement "<key>"; supported requirements: formula_compiler.

Explicit declaration rule

Graph-only constructs — check, retry, drain, on_complete (and tally, which requires it), and reserved gc.* step metadata (the section 2 kind values, gc.scope_name, gc.scope_role, gc.scope_ref, gc.continuation_group, gc.on_fail) — require an explicit declaration. Compiling without one must fail with:
requires: formulas that use graph-only constructs must declare [requires] formula_compiler = ">=2.0.0" or the deprecated contract = "graph.v2" explicitly
This check runs after expansions and aspects materialize, so composed-in constructs trigger it too.

Requirement composition

[requires] composes through extends as a safety constraint. A child inherits every parent requirement — including requirements implied by the deprecated contract = "graph.v2" — and may only add tighter constraints. Constraints contributed by composed expansion and aspect formulas are merged the same way. Non-overlapping constraint sets must fail before any durable work is written:
formula.compiler_requirement_conflict: formula "<name>" has non-overlapping formula_compiler requirements: <constraint> from <source>; ...
An unsatisfied constraint fails as:
formula.compiler_requirement_unsatisfied: formula requires formula_compiler <constraint> from <source>, but this city has formula compiler capability 1.0.0 because [daemon] formula_v2 is disabled
and compiling a v2 formula with the host switch off fails as:
formula "<name>" requires formula compiler v2 but formula_v2 is disabled; enable [daemon] formula_v2 or lower the formula requirements

Doctor and lint

The gc doctor check formula-requirements reports, per city and rig formula layer: parse failures (error), deprecated contract = "graph.v2" opt-ins (warning, message quoted above), missing explicit v2 requirements for graph-only constructs (error), and host mismatches including disabled formula_v2 (error). Its fix hint is:
replace deprecated contract = "graph.v2" with [requires] formula_compiler = ">=2.0.0"; enable [daemon] formula_v2 or lower requirements; fix invalid requirements and parent/child conflicts
gc lint warns on the deprecated gc.output_json fan-out in v2 formula steps (section 3.4). Warnings do not fail lint.

Differences from v1

Both contracts are supported. Two v1-side behaviors have no v2 equivalent yet; neither is a design commitment:
  • gc converge accepts only v1 formulas and rejects v2:
    convergence wisps do not support v2 formula "<name>"; use a v1 formula until convergence has an explicit input convoy target
    
    The v2 construct for iterate-until-verified semantics is the check loop (section 3.1); gc converge is the pre-v2 command for this pattern.
  • Container dependencies do not gate on children. Under v1, a step that needs a parent waits for all of that parent’s children; the v2 compiler creates no parent-child edges, so the same dependency gates only on the parent step itself. This is a tracked gap (#3451), not a contract guarantee — until it lands, enumerate the children explicitly in needs.
Conversely, routing a formula to a scale-from-zero pool requires a Ready-visible surface, which v1 molecule containers lack; gc sling rejects them:
formula "<name>" root is a molecule container, not Ready-visible work; scale-from-zero pools will not wake for this wisp. Convert the formula to phase="vapor"/root-only or formulas v2 before routing it to a pool

Deprecated surfaces

SurfaceStatusReplacement
contract = "graph.v2"Deprecated opt-in; gc doctor warns[requires] formula_compiler = ">=2.0.0"
<name>.formula.toml / <name>.formula.jsonDeprecated spellings (section 1.1)formulas/<name>.toml
JSON labels step keyDeprecated JSON spelling (section 1.3)TOML tags
Authored gc.output_json_required fan-outDeprecated; gc lint warns (section 3.4)drain (section 3.3)
{{issue}} variableDeprecated one-release compat alias (section 1.4){{convoy_id}} derivation
gc.model step metadataDeprecated; gc doctor migrates (section 2)opt_model
Last modified on June 13, 2026