ADR-0043: The foreach step, with an on_error policy and errors[]¶
Status: accepted (KAN-1858)
Context¶
market-etl has to carry on past a rate-limited symbol. ADR-0027 promised "foreach isolates errors per item and
exposes an errors[] run output" and named a foreach item list as an expression. Until now a failing step ended the
run (ADR-0041's validate and dedupe end it too, quietly or not), and a workflow was a flat list of steps with no
way to repeat some of them for each element of a list.
Decision¶
- Version 1.1 only. A workflow step with a
foreachkey (notype, as the step is named by what it does, likeactionsteps):
- step: each_symbol
foreach:
items: "trigger.symbols" # a whole JSONata expression, as `if` is
as: symbol # the loop variable, read by expressions as `symbol`
concurrency: 2 # 1 to 32, default 1
on_error: continue # fail (default) | continue | retry{...}
steps: # a nested step list
- step: fetch
type: tool
tool: fetch_price
- step: write
type: tool
tool: store_row
In a 1.0 spec the validator says foreach needs version "1.1". Every error carries the YAML line, including those in
nested steps (a nested step's path is agent.workflow[i].foreach.steps[j]...).
-
The body. Any existing step kind except another
foreachand approvals (below):action,tool,condition(with anexecute_tool(...)branch),transform,validate,dedupe. Expressions in the body read the loop variable and the outputs of the body steps that ran before them, in addition totrigger,input,varsand the workflow's own steps. The same names are visible to{{ }}in a tool the body calls (a tool is declared once, so the validator accepts any loop name there).asand the body step names may not be a reserved name (trigger,input,vars), the name of a workflow step, each other, or a duplicate. -
The result. The step's output (so
each_symbol.results,.errors,.skipped,.countin later expressions) is{ results, errors, skipped, count }: results: one object of body step outputs (by step name) for each item that completed, in item order. Failed and skipped items are not in it, so its length is notcount. Anyone who needs to match a result to its item reads the loop variable in atransform.errors:{ index, item, step, message }for each item that failed and was isolated.skipped:{ index, item, step, reason }for each item avalidatewithon_fail: skipor adedupeduplicate ended. Inside a loop these end the item, not the run (they end the run at the top level, ADR-0041). The natural use isdedupeon the item's id.count: how many items the list had.
The loop variable and the body step names are scoped to the loop; they are not left in the run outputs.
-
itemsmust be a list. It is evaluated once, before any item runs. A value that is not a list (a string, an object, a number) or nothing at all fails the step with a message that says what it got and how to wrap one value in a list. A list with one element is fine. -
Per-item error isolation. Body steps run in order for an item; the first one that fails ends that item. The steps after it do not run, so an item never leaves a partial result: a
store_rowafter a rate-limitedfetchis not called for that item.on_errorsays what happens next: fail(default): the run fails, naming the item index and the step. No new item starts; items already in flight finish. With concurrency the failure reported is the lowest index among them (deterministic).continue: the item goes toerrorsand the loop moves on.retry: { n, backoff?, base_ms?, max_delay_ms?, then? }: the failed step is re-run up tontimes (1 to 10), with the delays of the rest of the engine (backofffixedorexponential, default exponential;base_msdefault 500; capped atmax_delay_ms, default 30 s), slept on the injectableclock. Earlier steps of the item are not repeated, so a write that already happened is not done twice. Only a call that leaves the process (action,tool,conditioncalling a tool) is retried; a failed assertion or a missing value would fail again, so it goes straight tothen. When retries run out,thendecides:fail(default) orcontinue; the message ends "(after n retries)". ARetry-Afterheader is not read here, though a tool's ownretrypolicy (ADR-0029) still applies inside each attempt.-
gotois not yet supported (it needs conditional branching, KAN-1859).on_error: gotoandon_error: { goto: step }stay in the shape so the validator can say "on_error: goto is not yet supported" at the field's line; the engine refuses it too if a spec reaches it unvalidated. -
Bounded concurrency.
concurrencyitems are in flight at once, taken from the list in order (a pool of workers, not batches). Results are collected by item index, so their order does not depend on timing. The cap is 32. -
Events. The loop emits its own
step_started/step_completed(the completed one carries the output above), and each nested step emits both under the name<loop>[<index>].<step>(each_symbol[1].fetch). Events from concurrent items interleave; theindexin the name tells them apart. -
Rejected at validation, for now.
- A
foreachinside aforeach: "a foreach inside a foreach is not supported yet", at its line. It is accepted by the shape only ({ step, foreach }) so that message can be specific, and so the JSON Schema is not recursive. The engine refuses it too. -
Approvals in a body: an
approvalstep, aconditionbranchrequest_human_approval, atool(or branch tool) whose declaration hasrequires_approval: true, and aconfidence_gatestep whileguardrails.confidence_thresholdis set. A loop cannot pause for a person: the pause record of ADR-0042 is indexed by a top-level step, and N concurrent pauses would each need their own. At run time the validator cannot see an approval that a component op's effect implies (ADR-0030); that item fails with "approval is not supported inside a foreach body yet", whichon_errorisolates like any failure. Lifting the restriction needs the pause record to carry an item index, and a decision on what concurrent pending items mean. -
Export.
workflow.tsis vendored as before (the parity test compares it with the engine's;spec-types.tsgainsForEachStep/BodyStep). The generated README's dedupe-state note also fires for adedupein a loop body. -
Canvas. A foreach node shows
Foreach: <step>, a line with its list, variable,on_errorand concurrency, and its nested steps as a list; the nodes below it in the column are moved down to make room (display only, the sidecar layout is untouched). The Add Workflow Step form can create a foreach whose body is a list of tool steps. Nested steps are not editable on the canvas yet, nor are they separate nodes with edges (follow-up card).
Not in this card¶
goto(not built: abranchinside the body covers it, ADR-0045), a foreach in a foreach, and approvals inside a body.- Nested steps as their own canvas nodes with in-place editing, and a canvas form for non-tool body steps.
- A loop index variable (
<as>_index); an expression can use$countand the position in a list it already has. - The hosted server still refuses 1.1 specs (ADR-0027), so loops run locally and in exports only.