ADR-0048 — Workflows: branching, loops, logic steps, and parameterised tasks

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:

  1. 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_fail cannot express route to step X on success, step Y on failure, and cannot express go back and retry.

  2. Carrying script output forward. ADR-0019 amendment (b) item 4 forbids task steps 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: a task's captured stdout/stderr/exit_code flowing 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.

  3. 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_fail naming a target step or a reserved verb), bounded back-edges (loops, capped by per-step max_runs), a new kind: compare logic step, and parameterised task steps that append auto-quoted positional arguments. Task steps expose stdout / stderr / exit_code as 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
  • comparison is a verb (word, not symbol — gt, not >), extensible by future amendment.
  • a and b are bindable string fields (everything in the binding system is a string — §5). The verb defines how they are interpreted and cast.
  • A clean false routes through on_fail. An evaluation error (e.g. a numeric verb on a non-numeric value, or in with a non-array b) is not a false result — it is a hard abort with compare_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 ab 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.
  • with is 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 task step exposes three bindable outputs without declaring them: stdout (captured, bounded to the existing task ring-buffer limit), stderr (same), and exit_code (integer-valued, surfaced as a string per below). There is no outputs: block on task steps — 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's with: binds it.
  • All bound values are strings. "{{ ref }}" and "{{ ref }}_suffix" are both valid; interpolation is substring-level, not whole-value-only. Agent-step outputs: type declarations remain model-facing hints (they tell the agent to emit 7, not "more than five"); they do not change the fact that a bound value arrives as a string. Numeric compare verbs 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 in outputs:; for task steps, not one of stdout/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 without max_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 → commitend; 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_success on all step kinds; on_fail value-space widened to continue | abort | end | <step_id>; max_runs on all step kinds; new kind: compare discriminant with comparison/a/b; task-step with: array; removal of any outputs: affordance on task steps; reserved step-id words (continue/abort/end); new binding roots steps.<id>.run_count and self.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 of step_ref_forward and step_ref_skippable.
  • Daemon engine (workflow-envelope.ts + workflow-executor.ts): the sequencer changes from idx + 1 advancement to target resolution (verb-or-id), an entry-count map per step, back-edge handling, and compare evaluation. 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_code as 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 at kaged.dev/schema/v1.json. STATUS.md workflow 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 via on_success/on_fail only; 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 the max_runs failsafe 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 $N positional override / no overriding command/argv[0]. Task with: 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 compare without 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_unmet detail 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

  1. compare verb 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.
  2. 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.
  3. Run-record representation of executed path. Resolved (2026-06-22): lean. The workflow_invocation_steps row gains a run_count integer column, overwritten in place on each entry; outputs/task_output/error_code/started_at/ended_at reflect 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.
  4. max_runs interaction with workflow timeout_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_exceeded vs workflow_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 ADR
  • docs/specs/project-dsl.md — DSL schema spec for the step shapes
  • Original discussion: design conversation with colleagues, 2026-06-22