ADR-0045: The branch step (if/else and switch) and the stop step¶
Status: accepted (KAN-1859)
Context¶
Until now a workflow was a flat list. The one conditional, the condition step, picks between two strings
(execute_tool(name) or request_human_approval), so "answer if confident, otherwise escalate" and "ignore a
duplicate, otherwise open a ticket" needed a trick or could not be written. ADR-0043 left on_error: goto and ADR-0041
left validate on_fail: branch waiting for this card. The condition step stays as it is for 1.0 specs; it is not
removed or deprecated in 1.1.
Decision¶
- Version 1.1 only. A step with a
branchkey (notype, asforeachis written), in either of two forms:
- step: route
branch:
if: "triage.confidence > 0.8" # a whole JSONata expression that gives true or false
then: # a list of steps
- step: answer
type: tool
tool: reply
else: # optional; leave it out for "nothing to do"
- step: hand_off
type: tool
tool: escalate
- step: route
branch:
switch: "classify.label" # a string, number or boolean, compared as text
cases:
duplicate:
- step: ignore
stop: { reason: "duplicate {{ trigger.id }}" }
urgent:
- step: page
type: tool
tool: escalate
default: # optional
- step: answer
type: tool
tool: reply
if and switch are checked as every expression is (syntax, denied built-ins, every root name exists), at their
own line. In a 1.0 spec the validator says branch needs version "1.1". An arm is a list of any steps:
actions, tools, approvals, conditions, data steps, a foreach, another branch, a stop.
stopstop: { reason?, status? }ends the run successfully.reasonis text with{{ }}; with none it saysstopped at step "<name>".statusisskipped(the default) orcompleted:skipped: the run completes withskipped: { step, reason }, the same field ADR-0041'svalidate/dedupeset (run state, thecompletedevent,--json). It means "there was nothing to do".completed: the run completes with noskipped; the reason is only in the step's own output.
Either way the step's output is { stopped: true, status, reason } and nothing after it runs. Inside a foreach
body a stop ends only that item (as validate skip does there, ADR-0043): skipped puts the item in the loop's
skipped list with the reason, completed keeps it in results with the outputs so far.
-
Data model. Step outputs are one flat namespace, so a step in a taken arm leaves its output where the steps after the branch read it (
answer.status). The outputs of an untaken arm are absent: a later step that reads them evaluates to nothing, which atransformreports and aniftreats as false (ADR-0027). The branch step's own output says which arm ran:{ taken: "then" | "else" | "none" }forif;{ taken: "<case value>" | "default" | "none", value: "<the switch value as text>" }forswitch.noneis a branch with no matching arm and noelse/default; it is not an error. (A case literally nameddefaultornoneis ambiguous intaken; readvalue.) The branch step completes, and its output exists, before the arm's steps run. -
Switch matching. The value must be a string, number or boolean (nothing, a list or an object fails the step), and is compared as text to the case keys, so
1selects the case written1, andtrueselectstrue. -
Validation, each with the YAML line:
- An empty
then,else, case ordefaultis rejected (leaveelse/defaultout to do nothing); so is a switch with no cases. - A duplicate case value is rejected. Two identical keys are already a YAML syntax error; this also catches
1and"1", which YAML treats as different keys and a switch as one. - A step name used twice in a workflow is rejected when a branch is involved (a step in two arms, or in an arm and outside it). The names in all arms share the one flat namespace above. (A repeated name among top-level steps alone is not checked, as before.)
- A step after a
stopin the same list is "unreachable". So is a step after a branch whose every arm always ends the run, which needs a finalelse/defaultthat also ends it. - An expression in one arm that reads a step defined in a sibling arm of the same branch is rejected ("is a step
in another arm of branch ..."); so is the branch's own
if/switchreading a step of its arms. Reading an arm's step after the branch is allowed (it is present only if that arm ran). - Reserved names (
trigger,input,vars) are rejected for steps at any depth. -
A malformed step in an arm is reported at its own line, not at the branch.
-
Nesting with
foreach. Aforeachin an arm is fine. Abranchin a foreach body is fine: it runs per item with the loop variable and the body steps before it in scope, and its arms' step names are body-scoped (they may not repeat the loop variable, a workflow step name or each other). Aforeachin an arm in a body is still "a foreach inside a foreach". The restrictions on a body (no approvals, ADR-0043) apply to its branch arms as well, and theon_errorretry policy applies to the leaf steps of arms in a body as it does to the body's own. -
Approvals inside a branch work. The pause record of ADR-0042 gains
stepPath, where the paused step is:[2, "then", 0]is the first step of thethenarm of the third workflow step; the arm name isthen,else,case:<value>ordefault, and it nests ([1, "default", 0, "then", 0]).stepIndexis kept (the first element) and a record withoutstepPath(written before this card) resumes from[stepIndex]. Resuming goes straight back into the arm that was taken at the pause without evaluating the condition again (so a clock or an external change cannot send a resumed run down the other arm), runs the rest of the arm and the steps after the branch, and does not re-run the arm's earlier steps. The stored outputs include the branch step's own. If a spec edit removed the arm the spec hash already refuses the resume (ADR-0042); the engine also fails visibly if the arm is gone. Both the persisted and the in-memory path use the same path matching. -
Events and tracing.
step_started/step_completedcarry the plain step name for steps in an arm of the workflow (the namespace is flat), and<loop>[<index>].<step>for those in a loop body, as ADR-0043 names them. -
validate ... on_fail: branchstays rejected, with the message "on_fail: branch is not supported (use a branch step with the negated assertion and a stop instead)". The naturalon_fail: { branch: [steps] }is not small: it leaves open whether the run carries on after the steps (a failed assertion that carries on is not "failed"), and it needs the data step to run nested steps. The same thing is already written asbranch: { if: "not(<assertion>)", then: [..., { stop }] }. The enum value stays in the schema so the message is specific. -
on_error: gotostays rejected for the same reason (abranchinside the body decides what to do per item); its message now says so instead of waiting for this card. -
Export.
workflow.tsandpending-approval.tsare vendored as before (parity-tested);spec-types.tsgainsBranchStepandStopStep; the README's dedupe and pending-approval notes also look inside arms and loop bodies. The e2e equivalence test runs a branch, a switch, a stop and a per-item stop in an exported project. - Canvas. A branch node shows
Branch: <step>, itsif/switchexpression and each arm under a heading (then,else,case <value>,default) with its steps listed, read-only, like a foreach; a stop node says how it ends and why. The Add Workflow Step form creates an if/else branch whose arms are lists of tool steps, and a stop. Switch branches, nested non-tool steps and editing an arm in place are written in the YAML (follow-up). - The JSON Schema is now recursive: the workflow step is a
$refintodefinitions(zod emits it for a self-referencing schema), so the checked-in artifact changed shape, not only content.
Not in this card¶
- Arms as their own canvas nodes with edges and in-place editing, and a form for switch branches and arms of non-tool steps.
on_fail: { branch }andgoto, as above; a loopcontinue/break.- Approvals inside a
foreachbody (ADR-0043), including in a branch within one. - A dead-code check for a
switchwhose cases cannot all be reached, and exhaustiveness checks. - The hosted server still refuses 1.1 specs (ADR-0027), so branches run locally and in exports only.