ADR-0048 — Workflows: branching, loops, logic steps, and parameterised tasks
- Status: Accepted
- Date: 2026-06-22
- Deciders: @karasu, with colleagues
- Constrained by: ADR-0006, ADR-0012, ADR-0019, ADR-0022, ADR-0026, ADR-0037, ADR-0038
- Amends: ADR-0019
Context
The step model ratified in ADR-0019 amendment 2026-06-10 (b) and refined by ADR-0038 is strictly sequential: steps execute in array order, the only control affordance is per-step on_fail: abort | continue, and there is no way to route, repeat, or branch. Three concrete capabilities are missing for real recipes:
Branching. The motivating recipe is "run a build; on success commit; on failure hand the error to a fixer agent, then try the build again." Sequential-plus-
on_failcannot express route to step X on success, step Y on failure, and cannot express go back and retry.Carrying script output forward. ADR-0019 amendment (b) item 4 forbids
tasksteps from declaring outputs or accepting bindings, on the grounds that string-splicing invoker data into shell commands is an injection primitive. That blanket refusal also blocks the safe direction: atask's capturedstdout/stderr/exit_codeflowing into a downstream agent step (the fixer needs the build error to fix it). The spec already captures task output to the step record for run views — it is simply not bindable.Logic. With structured outputs available, branching should be able to key on a value (
if steps.x.output.count > 7 …), not only on step success/failure. Encoding every decision as a throwaway shell script that exits non-zero is silly when the data is already structured.
These are exactly the "conditionals / branch / loop" capabilities ADR-0019 amendment (b) item 3 and item 10 explicitly deferred ("v1.x questions; the schema shape … does not foreclose them"). This ADR takes them up.
A note on scope: the workflow execution engine is specced but not implemented (docs/specs/workflows.md status: "execution engine specced, not implemented"). There are no working workflows in the field. Backwards compatibility with the sequential-only shape is therefore not a constraint — this ADR changes load-bearing semantics freely, and the spec/schema are amended in lockstep before any engine code is written, per ADR-0003.
Decision
Workflow steps gain explicit routing (
on_success/on_failnaming a target step or a reserved verb), bounded back-edges (loops, capped by per-stepmax_runs), a newkind: comparelogic step, and parameterisedtasksteps that append auto-quoted positional arguments. Task steps exposestdout/stderr/exit_codeas implicit bindable outputs. Bindings are validated in two tiers — static where statically knowable, runtime where path-dependent — and a binding to an output not produced on the executed path aborts the invocation.
This supersedes the "sequential execution only; no branch/loop/parallel" decision (ADR-0019 amendment (b) items 3 and 8), the "task steps accept no bindings / declare no outputs" decision (amendment (b) item 4, partially — the no-string-spliced-shell guarantee is preserved, see §4), and the "bindings resolved statically at validation time" decision (amendment (b) item 4 — refined to two-tier, see §6).
1. Routing: on_success / on_fail name a target
Every step's on_success and on_fail take either a step id or a reserved verb. The reserved verbs are continue, abort, and end — these are forbidden as step ids (added to WorkflowNameSchema's reserved set for step ids).
| Field | Default | Meaning of value |
|---|---|---|
on_success |
continue |
continue → next step in array order. end → terminate invocation succeeded. <step_id> → jump to that step. abort → terminate failed (unusual on success, but legal). |
on_fail |
abort |
abort → terminate invocation failed with the step's error_code. continue → next step in array order (the old behaviour). end → terminate succeeded despite the step failing (explicit "failure is fine here"). <step_id> → jump to that step. |
The defaults reproduce today's behaviour exactly (on_success: continue, on_fail: abort), so a recipe that uses neither field reads identically to the sequential model. There is no goto keyword — the target is the routing value. A jump to a step earlier in the array is a back-edge (a loop); a jump to a later step is a forward skip. Both are the same mechanism.
on_success/on_fail apply to all three existing step kinds and to compare (§3). For confirm steps, "success" = confirmed, "failure" = cancelled/expired.
2. Loops are bounded by per-step max_runs
Any step may declare max_runs: <int ≥ 1>. It bounds the number of times execution may enter that step within one invocation. The counter increments on entry, so the first execution reads run count 1; with max_runs: 3, runs 1, 2, and 3 execute and a 4th entry is refused.
Hitting the cap is a hard abort, not a routed failure: the invocation terminates failed with max_runs_exceeded (step detail). max_runs is a runaway-loop failsafe, not a control-flow branch — a recipe is expected to terminate its loops via compare/on_* routing well before the cap. Routing through on_fail on cap-hit was considered and rejected (§Alternatives A): overloading on_fail with both "the step's work failed" and "the loop ran away" hides the failsafe.
A step with no max_runs and no incoming back-edge runs at most once (the sequential case). A step that is a back-edge target must declare max_runs (validation error step_loop_unbounded otherwise) — an unbounded loop is never authorable.
3. kind: compare — a logic step
A new step kind evaluates a comparison and routes via the existing on_success (comparison true) / on_fail (comparison false) verbs. It runs no model and no shell.
- id: enough
kind: compare
comparison: gt
a: "{{ steps.scan.output.error_count }}"
b: "0"
on_success: fix # error_count > 0 → go fix
on_fail: commit # error_count == 0 → go commit
comparisonis a verb (word, not symbol —gt, not>), extensible by future amendment.aandbare bindable string fields (everything in the binding system is a string — §5). The verb defines how they are interpreted and cast.- A clean
falseroutes throughon_fail. An evaluation error (e.g. a numeric verb on a non-numeric value, orinwith a non-arrayb) is not afalseresult — it is a hard abort withcompare_eval_error. A type mismatch is an authoring bug, not a logic branch.
The v1 verb set:
| Verb | True when | Casting / b shape |
|---|---|---|
eq |
a equals b |
string equality ("7" eq "7" → true) |
neq |
a ≠ b |
string equality |
lt / lte / gt / gte |
numeric ordering | both cast to number; cast failure → compare_eval_error |
includes |
a contains b as a substring |
string |
starts_with / ends_with |
a has b as prefix / suffix |
string |
in |
a is an element of b |
b is a YAML array literal (["a","b"]); entries individually bindable |
matches |
a matches regex b |
b is an re2 pattern (no backreferences/lookarounds), consistent with input pattern |
Verbs are words rather than symbols deliberately: === vs == semantics differ across languages and confuse readers; eq/neq/gt are unambiguous and the set extends cleanly. in takes a YAML array for b (not a delimiter-split string) — reusing the language's own list syntax avoids inventing a quoting/escaping convention and lets array entries contain commas.
compare participates in max_runs like any step (a compare inside a loop may be the cap-bearing step).
4. Parameterised tasks: append-only, auto-quoted positional args
A task step may carry a with: ordered array of argument tokens. Each token is appended, in order, after the task's declared command argv. There is no positional $N indexing into the existing argv — replacing or interleaving declared arguments is too error-prone and too dangerous to expose. Append-only is the entire surface.
tasks:
build:
command: ./script.sh blah blah # declared argv: [./script.sh, blah, blah]
# in a workflow step:
- id: run_build
kind: task
task: build
with:
- "{{ steps.fix.output.target }}" # bindable
- "--verbose" # literal
# effective argv: [./script.sh, blah, blah, '<target>', '--verbose']
Safety rules (these preserve the no-string-spliced-shell guarantee that ADR-0019 amendment (b) item 4 was protecting):
- Each token is one argv element, passed as a discrete argument to the task process. Tokens are never concatenated into a command string and never re-parsed by a shell.
- The daemon controls quoting. Each token is single-quoted with embedded single-quotes escaped (
'→'\''). Consequently$VAR, backticks,;,|,&&, glob characters, and every other shell metacharacter are inert — they are literal data inside the argument. An author who writes"$HOME"gets the four literal characters, not an expansion. This is intentional and the behaviour is documented, not a bug to fix. - Tokens are bindable (
{{ inputs.* }},{{ steps.*.output.* }}), including intra-token interpolation ("--msg={{ steps.x.output.summary }}"→ one argv element--msg=<value>). The whole token is still single-quoted as one element, so interpolated content cannot break out into additional arguments or shell syntax. withis an array, never a scalar string. A single string field would force the author to do their own quoting ("'foo' 'bar'") and get it wrong; the array makes each element's boundary explicit and quoting the daemon's job.- Append-only means a task's declared argv is always a prefix of the effective argv — the operator's
command:is never rewritten, only extended.
This replaces "task steps accept no bindings at all" with "task steps accept bindings only as discrete, daemon-quoted, append-only argv tokens." The injection primitive (string-spliced shell) is still not built; safe argv passing is.
5. Task outputs are implicit; everything is a string
- Implicit task outputs. Every
taskstep exposes three bindable outputs without declaring them:stdout(captured, bounded to the existing task ring-buffer limit),stderr(same), andexit_code(integer-valued, surfaced as a string per below). There is nooutputs:block ontasksteps — declaring them was redundant boilerplate, and an undeclared-but-unused output costs exactly what a declared-but-unused one does (nothing). Reading remains explicit opt-in: an output reaches a downstream step only if that step'swith:binds it. - All bound values are strings.
"{{ ref }}"and"{{ ref }}_suffix"are both valid; interpolation is substring-level, not whole-value-only. Agent-stepoutputs:typedeclarations remain model-facing hints (they tell the agent to emit7, not"more than five"); they do not change the fact that a bound value arrives as a string. Numericcompareverbs cast at evaluation time (§3).
6. Two-tier binding validation; loop output overwrite
ADR-0019 amendment (b) item 4 resolved all bindings statically at validation time. Branching and loops make some references path-dependent, so validation splits:
- Static (DSL validate-time). Because
{{ … }}markers, the set of step ids, and each step's output surface are all statically knowable, the validator still rejects: malformed binding syntax (step_binding_syntax); a reference to a step id that exists nowhere in the workflow (step_ref_unknown_step); a reference to an output name the target step cannot produce (step_ref_undeclared_output— for agent steps, not inoutputs:; for task steps, not one ofstdout/stderr/exit_code); unknown input names (step_ref_unknown_input); routing to a verb-or-id that doesn't resolve (step_route_unknown); a back-edge target withoutmax_runs(step_loop_unbounded). - Runtime (on step entry). Whether a referenced output was actually produced on the path taken is path-dependent and checked when the consuming step is entered. A binding to an output that has not been produced on the executed path — the producing step hasn't run yet, or ran on a different branch — aborts the invocation with
step_dependency_unmet(step + reference detail). The forward-reference and skippable-dependency static rules from amendment (b) item 4 are withdrawn: forward references and cross-branch references are now legal to write and enforced at runtime. Authoring a dependency that can't be satisfied on some path is the author's bug, surfaced as a clean runtime abort rather than a static over-approximation that forbids valid graphs.
Loop overwrite. When a step re-runs via a back-edge, its outputs are replaced: {{ steps.x.output.y }} always resolves to the most recent completed run of x. There is no history of prior runs' outputs in v1.
7. Internal step variables
Two non-output bindable references expose step execution metadata, as a third binding root alongside inputs. and steps.<id>.output.:
| Reference | Resolves to |
|---|---|
{{ steps.<id>.run_count }} |
The number of times step <id> has been entered so far this invocation (1-based; 0 before first entry). |
{{ self.run_count }} |
The current step's own run count, without naming itself. During run N this reads N. |
These let a loop self-adjust — e.g. a fixer agent's kickoff can say "attempt {{ self.run_count }} of 3; if this is the last attempt, prefer the safe fix." run_count is metadata, not an output, hence the separate root (steps.<id>.run_count, not steps.<id>.output.run_count). v1 ships only run_count; the root is open for future metadata.
Worked example — the motivating recipe
workflows:
build_and_fix:
description: Build; on failure let an agent fix using the error and retry; on success commit.
inputs: {}
tools:
allow: ["file.read", "file.write", "shell.*", "git.commit"]
steps:
- id: run_build
kind: task
task: build
max_runs: 3 # failsafe; loop should exit via routing first
on_success: commit
on_fail: fix # branch to the fixer instead of aborting
- id: commit
kind: agent
prompt: project:/workflows/steps/commit.md
tools: { allow: ["git.commit"] }
on_success: end
- id: fix
kind: agent
prompt: project:/workflows/steps/fix.md
with:
build_stderr: "{{ steps.run_build.output.stderr }}" # explicit opt-in
build_stdout: "{{ steps.run_build.output.stdout }}"
exit_code: "{{ steps.run_build.output.exit_code }}"
attempt: "{{ self.run_count }}"
tools: { allow: ["file.read", "file.write", "shell.*"] }
on_success: run_build # back-edge: retry the build
run_build → success → commit → end; failure → fix → back to run_build. The max_runs: 3 on run_build guarantees termination even if the fixer never succeeds (4th entry → max_runs_exceeded abort).
Consequences
What this commits us to
- DSL schema (
@kaged/dsl):on_successon all step kinds;on_failvalue-space widened tocontinue | abort | end | <step_id>;max_runson all step kinds; newkind: comparediscriminant withcomparison/a/b; task-stepwith:array; removal of anyoutputs:affordance on task steps; reserved step-id words (continue/abort/end); new binding rootssteps.<id>.run_countandself.run_count. - Validation: the two-tier split (§6) — new static codes (
step_ref_unknown_step,step_route_unknown,step_loop_unbounded) and new runtime codes (step_dependency_unmet,max_runs_exceeded,compare_eval_error); withdrawal ofstep_ref_forwardandstep_ref_skippable. - Daemon engine (
workflow-envelope.ts+workflow-executor.ts): the sequencer changes fromidx + 1advancement to target resolution (verb-or-id), an entry-count map per step, back-edge handling, andcompareevaluation. Step records gain a run-count / per-run dimension (storage change in the spec). - Task bridge: append-only argv quoting in the task-step dispatch path; capture of
stdout/stderr/exit_codeas bindable outputs (capture already exists for run views; this makes it bindable). - Spec amendments in lockstep:
docs/specs/workflows.md(§Steps, §Bindings, §Execution semantics, §Failure handling, §Validation matrix, §Error taxonomy, §Storage run-count dimension),docs/specs/project-dsl.md(workflow step schema), and the JSON Schema atkaged.dev/schema/v1.json.STATUS.mdworkflow rows. - Per ADR-0003: no engine code ships with this ADR. Spec amendment first, failing tests next, implementation after.
What this forecloses
- No free
goto. Routing is viaon_success/on_failonly; there is no statement-level jump and no jump out of the middle of a step. A step has exactly two outgoing edges (success, failure) plus themax_runsfailsafe edge (always abort). - No unbounded loops. Every back-edge target must declare
max_runs. There is no "loop forever until the agent decides to stop" — the failsafe is mandatory. - No string-spliced shell, still. Task parameters are discrete, daemon-quoted, append-only argv tokens. Shell metacharacters in tokens are inert. The injection primitive amendment (b) item 4 refused is still refused.
- No
$Npositional override / no overridingcommand/argv[0]. Taskwith:appends; it cannot replace the operator's declared command or interleave its arguments. - No output history across loop iterations. A re-run overwrites; prior iterations' outputs are gone. Accumulating across iterations is a v1.x question.
- No parallelism. This ADR adds branching and loops; concurrent step execution remains out of scope (the at-most-one-non-terminal-step invariant from the spec holds).
What becomes easier
- Real recipes with success/failure paths and retry loops are authorable declaratively (the motivating build-fix-retry recipe).
- Script output (
stderr/exit_code) flows safely into agent steps that need it — the fixer can see the error it must fix. - Value-keyed decisions via
comparewithout abusing shell exit codes. - Self-adjusting loops via
self.run_count.
What becomes harder
- The execution model is no longer "read top to bottom." Run views must show the executed path and per-step run counts, not array order — a spec'd storage and run-record change.
- Validation is two-tier; authors can write a recipe that parses clean but aborts at runtime on an unsatisfiable cross-branch dependency. Mitigated by precise
step_dependency_unmetdetail naming the step and reference. - More step kinds and more routing surface to learn. Mitigated by defaults that reproduce the sequential model when routing fields are omitted.
Alternatives considered
Alternative A — max_runs cap-hit routes through on_fail
Why tempting: Uniform — every non-success outcome flows through one edge; authors could write on_fail: give_up to handle a runaway loop gracefully.
Why rejected: It overloads on_fail with two semantically different events — "the step's own work failed" and "the loop ran away" — so an author handling ordinary failure silently also swallows the failsafe. The cap is a failsafe, and a failsafe that can be routed around is not one. Cap-hit is a hard abort (max_runs_exceeded); loops are expected to exit via compare/routing long before.
Alternative B — A goto keyword (flat label model)
Why tempting: Maximum flexibility; a single jump primitive expresses every graph.
Why rejected: Free goto maximises the invariant damage branching causes — arbitrary back-edges into the middle of unrelated branches, ambiguous data dependencies, dead-step and reachability analysis that's hard to make precise. The operator's own framing ("feels hacky and not intuitive") matches the engineering reality. Two structured edges per step (on_success/on_fail) plus a mandatory loop bound expresses the target recipes while keeping the graph analysable.
Alternative C — Keep task steps output-free; wrap scripts in agent steps
Why tempting: Zero change to the amendment (b) item 4 security stance; the existing agent + shell.* escape hatch already lets an agent run a script and emit parsed outputs.
Why rejected: It forces every script whose output matters to be wrapped in a full agent run — paying model cost and latency to do what is fundamentally "run a command, read its stderr." The dangerous direction (invoker data → shell string) stays closed; the safe direction (captured output → agent input) should be open. B2 (implicit task outputs + append-only quoted args) opens exactly the safe direction.
Alternative D — Declared task outputs (explicit outputs: on task steps)
Why tempting: Symmetric with agent steps; explicit is conventionally better than implicit.
Why rejected: A task's output surface is fixed and known (stdout/stderr/exit_code) — there is nothing for the author to choose, so a declaration block is pure boilerplate. An undeclared-and-unused output costs the same as a declared-and-unused one. Implicit is correct precisely because the surface is not author-variable.
Alternative E — Typed bindings (preserve output type through to consumers)
Why tempting: compare gt wouldn't need to cast if a numeric output stayed numeric end-to-end.
Why rejected: Bindings interpolate into strings ("{{ a }}_{{ b }}"), and invoker/agent-supplied values are fundamentally text on the wire. A type system that's true at the producer and lost at the first interpolation is a false promise. Treating everything as a string with explicit cast-at-comparison is honest and keeps interpolation uniform; the output type stays a model-facing hint.
Open questions
compareverb growth. v1 ships the set in §3. Likely future additions:not_in,not_matches,empty/not_empty, length comparisons. Each is an additive amendment; the verb-word design accommodates them without grammar change.- Loop output accumulation. v1 overwrites on re-run. Some recipes may want "collect each iteration's output" (e.g. a list of attempts). Deferred; would need an explicit accumulator construct, not silent history.
- Run-record representation of executed path. Resolved (2026-06-22): lean. The
workflow_invocation_stepsrow gains arun_countinteger column, overwritten in place on each entry;outputs/task_output/error_code/started_at/ended_atreflect the most recent run only. No per-run history table in v1 — this matches the §6 loop-overwrite semantics (prior iterations' outputs are gone). Per-run history (open question 2) would supersede this with per-run rows if a future recipe needs accumulation. max_runsinteraction with workflowtimeout_seconds. Both bound runaway execution from different angles (count vs walltime). Whichever fires first wins; the spec states the precedence and the distinct error codes (max_runs_exceededvsworkflow_timeout).
References
- ADR-0003 — doc-first; spec and schema amended before engine code
- ADR-0006 — YAML DSL the step schema extends
- ADR-0019 — workflows; this ADR amends its sequential-only and task-no-bindings decisions
- ADR-0022 — per-agent tool surface the step intersection respects
- ADR-0026 — spend gate each agent-step dispatch still flows through
- ADR-0037 — explicit composition principle (scalars inherit, content composes explicitly)
- ADR-0038 — mandatory steps, per-step model; this ADR builds on its step model
docs/specs/workflows.md— the spec amended in lockstep with this ADRdocs/specs/project-dsl.md— DSL schema spec for the step shapes- Original discussion: design conversation with colleagues, 2026-06-22