Loop blocks (until)
Warden sagas support a single-level loop block: a bounded do-while over nested reason / commit steps. After each successful body pass, the engine evaluates until.cel. Loops are always bounded by required max_iterations.
Authoring
Loop bodies compose catalog steps the same way top-level saga steps do — use: refs, not inline capability blobs:
steps:
- id: refine
kind: loop
max_iterations: 5
until:
cel: "steps.validate.facts.ok == true"
steps:
- id: attempt
use: attempt-step
version: "1.0.0"
with: {}
- id: validate
use: validate-step
version: "1.0.0"
with: {}
- id: finalize
use: finalize-step
version: "1.0.0"
with: {}
Rules:
max_iterationsis required and must be>= 1.until.celis required and compile-checked at deploy.- Loop bodies may contain catalog step refs only (no nested loops, spawn, or join in v1).
- Multiple sibling loops in one saga are allowed; each has an isolated
context.loops.<id>bucket. - Step ids must be unique across the whole blueprint, including every loop body.
when.celinside a body is allowed;SKIPPEDcounts as a clean step completion and clears that step’s latest-winscontext.steps.<id>entry for the current body pass (sountil.celcannot see a prior iteration’s output).
Runtime model
- Delay-tail materialization: saga start creates only
[prefix] → [first loop iteration 1 body]. Steps after the active loop are minted whenuntil.celbecomes true (or when entering the next sibling loop). forward_seq: every forward row gets a monotonic execution sequence. Scheduling walksforward_seqASC; compensation walksforward_seqDESC.- Latest-wins context:
context.steps.<id>reflects the latest iteration’s outcome (including an empty shell afterSKIPPED). History lives onSagaStepInstancerows (loop_id,iteration,forward_seq). - Hard fail: any body step failure or policy/HITL reject aborts the saga (no further iterations).
- Exhaustion: if
untilstays false aftermax_iterations, the saga fails withLOOP_EXHAUSTEDand compensates.
Compensation
Undo resolves compensation with paths against the forward row being compensated (output_payload / resolved_arguments), not live context.steps, so iteration N cannot bleed into iteration 1's undo.
CEL bindings
until.cel (and when.cel) see input, steps, loops, saga, plus loop: { id, iteration, max_iterations } when evaluating inside a loop.
Smoke path: max_iterations: 1 with until.cel: "true" exits after one body pass and materializes the tail.