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:
- The orchestrator executes every control bead. The control dispatcher in
internal/dispatchevaluates 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.
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 informulas/ 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:
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) resolvedgc formula show/cook/slingfirst-wins, letting imported pack formulas shadow same-name city overrides. If an override does not take effect, inspectgc 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.
{{key}} placeholders substitute into descriptions, titles, notes,
assignee, and metadata values. Values are supplied as key=value pairs at
instantiation:
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 withconvoy_id requires a targeted formulas v2 invocation), and any reference forces a targeted invocation (gc sling --on <formula>orgc formula cook --attach); an untargeted invocation fails withv2 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 stepcondition 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):
max budget as a loop: label on the first body step:
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.
phaseis taken from the child, else the first parent that declares one.pouris monotonic: any ancestor’spour = truesticks; a child cannot opt out.contractandrequirescome from the child, else the first parent — and requirement constraints from every parent are still collected and validated as a set (section 5).- Circular
extendschains must fail (circular extends detected: a -> b -> a).
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.
1.8. Description Files
Usedescription_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:
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 inlinedescription.)
1.9. Validation
A formula must satisfy these structural rules; violating any is a validation error:formulaname is required.contract, if set, must be"graph.v2"(contract: invalid value "<value>" (must be graph.v2)).typemust beworkflow,expansion, oraspect.- A var must not combine
required = truewith adefault. - Step
idis required and globally unique, includingchildren. - Step
titleis required unlessexpandis set. prioritymust be 0–4.depends_on/needsentries must reference known step IDs (including children).waits_formust matchall-children,any-children, orchildren-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_eachandbondmust be set together;for_eachmust start withoutput.;parallelandsequentialare mutually exclusive.tallyis removed; formulas that still declare[steps.tally]fail withsteps.tally was removed from the SDK.check:max_attempts≥ 1; the innerchecktable is required withmode = "exec"(the only supported checker) and a non-emptypath. 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_exhaustedmust behard_failorsoft_fail.drain: see section 3.3 for field constraints and incompatibilities.timeoutmust be a positive Go duration and requirescheck.- Loops (validated during control-flow expansion):
bodynon-empty; exactly one ofcount/until/range;maxrequired withuntil.
2. Compilation
Compilation is a fixed pipeline: load → resolveextends →
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
blocksedges fromneeds/depends_on(and readiness-blockingwaits-foredges fromwaits_for). The compiler creates no parent-child edges between graph steps; nesting inchildrenaffects ID namespacing and validation, not runtime hierarchy. workflow-finalizeis appended. A control step with IDworkflow-finalize(kindworkflow-finalize) is added depending on every sink step, so it becomes Ready exactly when all other work is terminal. Steps carryinggc.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-finalizethrough an informationaltracksedge. 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 whosegc.kindis not one of the generatedrun,check,retry-run,retry-eval, orspeckinds. The root is controller-owned; step beads are the work that wakes agents and pools. - Non-blocking
tracksedges to the root. Batch instantiation connects every non-root node to the root with atracksedge 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.
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:
- 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. - 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.
- 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.
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.
<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:
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.
retry); attempts are
<step>.attempt.N. The orchestrator classifies each closed attempt:
- pass — the control closes
gc.outcome = pass, copying the attempt’sgc.output_jsonand non-gc.*metadata upward. - hard — the control closes
gc.outcome = failwithgc.final_disposition = hard_fail; no further attempts. - transient — with budget left, the orchestrator spawns the next
attempt; at
max_attempts, exhaustion applieson_exhausted:hard_failcloses the controlgc.outcome = failandgc.final_disposition = hard_fail;soft_failcloses itgc.outcome = passwithgc.final_disposition = soft_failso 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.
<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 withgc.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.
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
untilloop validates the condition and writes aloop:{"until":...,"max":...}label on the first body step (the loop expander ininternal/formula/controlflow.go), but no component — neither the v1 cook path nor the v2 control dispatcher — reads that label. Anuntilloop therefore runs exactly one iteration. Usecheck(section 3.1) for orchestrator-driven re-execution. - Gate
typevocabulary.[steps.gate]synthesizes a real gate bead that blocks its step until the gate bead is closed (manually or by an external watcher), but thetypevaluesgh:run,gh:pr,timer,human, andmailare doc-comment vocabulary ininternal/formula/types.go— the parser never validates them and no bundled watcher acts on them. Zero bundled formulas usegate. waits_forgate modes.waits_forcompiles to a readiness-blockingwaits-fordependency edge plus a recorded gate mode, but no current dispatcher logic interprets theall-children/any-childrendistinction. Zero bundled formulas usewaits_for.vars.<name>.type. The variabletypefield (string,int,bool) is parsed into the variable definition (internal/formula/types.go) but never enforced; onlyrequired,enum, andpatternare 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:
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:
Doctor and lint
Thegc 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 convergeaccepts only v1 formulas and rejects v2:The v2 construct for iterate-until-verified semantics is the check loop (section 3.1);gc convergeis the pre-v2 command for this pattern. -
Container dependencies do not gate on children. Under v1, a step that
needsa 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 inneeds.
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: