Skip to main content
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 orchestrator’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

0.1. Concept

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. The method is defined independently of how its work is stored; the data model below is the current implementation of that storage, not part of the definition of a formula.

0.2. Data Model

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:
Execution responsibility is split by bead kind:
  • The orchestrator executes every control bead. The control dispatcher in internal/dispatch evaluates check and retry budgets, expands fan-outs, 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: A minimal v2 formula:

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, 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: Formula directories are collected into layers, ordered lowest to highest priority: 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:
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

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.
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.
Table-form fields: {{key}} placeholders substitute into descriptions, titles, notes, assignee, and metadata values. Values are supplied as key=value pairs at instantiation:
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: 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. A range loop (the same shape works with count):
gc formula show hanoi renders the chained iterations (the workflow-finalize step comes from the v2 contract — section 2):
An until loop expands a single iteration and records the condition and max budget as a loop: label on the first body step:
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 orchestrator-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:
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 is removed; formulas that still declare [steps.tally] fail with steps.tally was removed from the SDK.
  • 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. Steps carrying gc.scope_role = "teardown" are excluded from the sink set: teardown runs after the workflow settles (section 3.5), so gating settlement on it would deadlock the run.
  • The root tracks the finalize step. The workflow root reaches workflow-finalize through an informational tracks edge. A blocking edge would prevent the finalizer from closing the root while it is still open. When a recipe has no finalize step, the root instead depends on every step whose gc.kind is not one of the generated run, check, retry-run, retry-eval, or spec kinds. The root is controller-owned; step beads are the 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): 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_<key> step metadata, where <key> is any key of the provider’s options schema (for example opt_model, opt_effort). They are validated against that schema, and invalid values are skipped per key. They apply when a session is launched, from the first of these cases that holds:
  1. The session holds a claimed (in-progress) bead in the store its session bead lives in. A claimed bead with opt_* metadata supplies the options; if none has any, the launch keeps its default options and the trigger bead is not consulted.
  2. The session’s trigger bead (the bead it was spawned for — the routed demand a pool slot starts on) is not claimed yet, and the session holds a claimed bead in any store it can claim from, other than a suspended rig’s store, which is not read. The trigger is ignored and the launch keeps its default options.
  3. Otherwise the trigger bead supplies the options while it is still the session’s step (not closed, and unassigned or assigned to the session), including when the session has already claimed it. A trigger the session has claimed skips the check of other stores, so a session holding several claims launches with the options of one claimed bead.
An explicit session template_overrides value wins per key. Options are launch flags: a warm session reused for a later step keeps the flags it launched with, and a pool slot keeps the options it launched with even if it later claims a different ready bead. gc.model is a deprecated spelling that the gc doctor check work-option-metadata-migration migrates to opt_model. Role target aliases. In the value of gc.run_target, gc.<role> is a semantic role alias used by imported role packs. The resolver first treats the complete value as an exact configured agent identity; this preserves an agent actually named or bound as gc.<role>. Only when that exact identity is absent does it retry the bare <role> within the workflow’s rig context. This value alias does not change the separate reservation of gc.* metadata keys. 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):
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:
gc sling <target> <formula> --formula starts the workflow and routes the step graph:

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. Attach on a split city. A city that serves the graph coordination class from its own [storage] binding refuses most of gc formula cook --attach rather than serving it. A graft is graph class whatever the formula’s version — every bead it materializes carries gc.root_bead_id — so the sub-DAG belongs in the binding while the blocking dependency belongs beside the attach bead, and one store cannot hold both: A rig scope — --rig, GC_RIG, or a cwd inside a rig — follows the same placement rule as the city: every rig’s graph beads live in the binding, recorded with the rig as their scope (gc.root_store_ref), so a rig’s own store holds only work. An older tree, one the rig created before its graph beads moved to the binding, stays in the rig’s own store, and a v1 graft onto one of its beads extends it there. Both refusals are lifted by the same missing mechanism, a cross-class membership edge (ga-2orlf). Cities that author no [storage] section are unaffected and --attach behaves exactly as it always has. Control dispatch. The orchestrator’s control dispatcher processes every open control bead by gc.kind: retry, ralph, check, retry-eval, fanout, 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 orchestrator; 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 orchestrator runs the configured script. Exit 0 closes the step; an infrastructure outcome — a run stopped at its timeout, exit 75, or the narrow stderr fallback described below — re-runs the script without spending an attempt; any other nonzero exit is a “not yet” verdict that spawns the next iteration while budget remains, and exhaustion closes the step as failed.
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:
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). A hard failure ends the loop. When an iteration closes gc.outcome = fail with gc.failure_class = hard — the iteration’s own work bead, or, for a check step with children, the member whose hard failure aborted the iteration scope — the orchestrator closes the check step failed in that iteration without running the check script: gc.failure_class = hard, the member’s gc.failure_reason, and gc.final_disposition = hard_fail. A transient or missing class leaves the loop to iterate while attempts remain. check must not be combined with loop, on_complete, gate, expand, assignee, or retry. Infrastructure outcomes — exit 75. A check script reports two different things with a nonzero exit: “the thing I verify is not true yet” (a verdict), and “I could not reach the infrastructure I need in order to tell” (a blind read). Only the first should cost an attempt. A blind read that is counted as a verdict spends the step’s budget on a question the script never answered. Exit status 75 is the script’s opt-in way to declare the second. It is EX_TEMPFAIL from sysexits.h — the same convention scripts/push-gate-lock-lib.sh already uses — and the orchestrator records that run as an infrastructure outcome rather than a verdict: Attempt-free re-runs are themselves bounded by a separate infrastructure budget, counted in gc.check_infra_retry on the step, so a script that exits 75 or times out forever still terminates; it just does not burn the semantic budget on the way there. No Go code inspects what the check was verifying — exit 75 is the signal a script declares deliberately, and the one to write against. It is not the only route to an infrastructure outcome, though: the stderr fallback below can reclassify a nonzero exit from a script that never exits 75. As a fallback for check scripts that only propagate a gc/bd failure and its bare exit status, the orchestrator also recognizes a small fixed set of typed infrastructure error strings on stderr (internal/convergence/gate_infra.go). That table is deliberately narrow, is matched only against stderr, and is not a stable interface — a script that needs this behavior should exit 75. Scope (non-normative for other lanes): this reclassification is applied by the ralph check lane described in this section. The other condition consumers — trigger conditions, hybrid dispatch, and gc converge — still read a nonzero gate as a genuine verdict. Each lane opts in separately, because “re-run without cost” only means something where there is an attempt budget to protect. A check that cannot be launched. When check.path names a script that is missing, is not a regular file, or is not executable, the orchestrator never runs it, so there is no exit status to read. The step stays open rather than failing: the fix is out of band (ship the script, chmod +x it), and closing the step would release the steps that need it. The orchestrator re-checks the step every sweep; once the script can run, the step resumes and spends attempts normally. If the script is still unlaunchable when the stall budget elapses (15 minutes by default), the orchestrator emits a single control.stalled event with error_class = "pending" and keeps waiting. It records no order.failed. A script that is missing because the step’s working directory was removed, and that exists nowhere else, is held open the same way. Restoring the directory heals it; shipping a copy under the city or store root does not. The script always runs inside the step’s working directory, so a copy found elsewhere still cannot start there. A failed start is not held open: it spends an attempt each time, and the step closes failed once its attempts run out. A check.path refused on safety grounds is not in this lane: one that escapes the trusted roots, climbs out with ../, or follows a symlink outside the city or store. Such a step is refused and closed failed. A symlink whose target does not exist yet is the exception: where it leads is checked only once the target exists, so until then it is held open as missing. Once the target appears, a target outside the city or store is refused.

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 orchestrator classifies as transient failures.
The control bead keeps the original step ID (kind retry); attempts are <step>.attempt.N. The orchestrator 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 orchestrator 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. A blocks edge is satisfied by its predecessor’s close whatever gc.outcome that close carries, but a drain does not expand when a direct blocking predecessor in the same workflow closed with a failed outcome: it reserves no members, creates no unit convoys or item roots, and closes with gc.outcome = fail and gc.failure_reason = predecessor_failed, naming the predecessor in gc.failure_subject. The steps that need it and workflow-finalize still run, and they see the failure: a drain chained behind it does not expand either, and the run finalizes fail with its source bead left open. A drain inside a scope aborts the scope, like any failed scope member.
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. The <step>-fanout control converges the spawned children: it blocks on the source step and closes only after the fan-out work completes. Every downstream needs / depends_on reference to the source is rewritten to the fan-out control (for a scoped source, its minted <step>-scope-check also blocks on the fan-out control), so downstream work waits for the spawned children instead of racing them. [steps.tally] was removed from the SDK. Use pack-level workflow logic to aggregate fan-out results if you need a synthetic pass/fail decision. Any formula that still declares [steps.tally] fails fast with steps.tally was removed from the SDK.
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.
Failure policy. gc.on_fail = "abort_scope" is the only specified value of gc.on_fail. When a scoped member fails, the orchestrator 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. When the failing member closed with gc.failure_class = hard, the body also keeps that failure record: gc.failure_class = hard, the member’s gc.failure_reason, and gc.failure_subject naming the member. 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 orchestrator 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. Teardown is post-settlement. Teardown work (gc.scope_role = "teardown") is the one part of a workflow that outlives settlement: it never blocks workflow-finalize, and finalize’s terminal close — which skips every other still-open member once the root is terminal — leaves the teardown step and its retry attempts open so they still run. A teardown step may therefore read the run’s final gc.outcome, which is what makes “clean up on pass, preserve the workspace on fail” expressible. Its own outcome never re-grades the root; a teardown that fails after settlement is a relic to sweep, not a failed run. Close-ownership invariant. A compiled graph never blocks a node on the control bead that closes it. A scope body is not blocked by any of its scope-checks (the body’s authored needs keep naming the raw members), and a workflow root is not blocked by its workflow-finalize (the root reaches its finalizer through an informational tracks edge instead). Such an edge is a permanent deadlock — the store refuses to close a blocked issue, and the only bead that could clear the blocker is the one being refused. The compiler rejects any recipe that contains one. Downstream ordering is unaffected: the scope-check still blocks on its member, and the finalizer still blocks on every graph sink including the scope body.

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 orchestrator-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

The canonical declaration:
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 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:
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:
An unsatisfied constraint fails as:
and compiling a v2 formula with the host switch off fails as:

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:
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:
    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 the v1 container materialization lacks; gc sling rejects them. The remedy in the error text below is migration to formulas v2; the phase="vapor"/root-only alternative it names is legacy v1 materialization mechanics (section 1.2), not a v2 authoring choice:

Deprecated surfaces

Last modified on October 29, 2026