Skip to main content

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_iterations is required and must be >= 1.
  • until.cel is 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.cel inside a body is allowed; SKIPPED counts as a clean step completion and clears that step’s latest-wins context.steps.<id> entry for the current body pass (so until.cel cannot see a prior iteration’s output).

Runtime model​

  1. Delay-tail materialization: saga start creates only [prefix] → [first loop iteration 1 body]. Steps after the active loop are minted when until.cel becomes true (or when entering the next sibling loop).
  2. forward_seq: every forward row gets a monotonic execution sequence. Scheduling walks forward_seq ASC; compensation walks forward_seq DESC.
  3. Latest-wins context: context.steps.<id> reflects the latest iteration’s outcome (including an empty shell after SKIPPED). History lives on SagaStepInstance rows (loop_id, iteration, forward_seq).
  4. Hard fail: any body step failure or policy/HITL reject aborts the saga (no further iterations).
  5. Exhaustion: if until stays false after max_iterations, the saga fails with LOOP_EXHAUSTED and 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.