Skip to content

Failure and Recovery

Recovery is layered, and most files declare none. The connector manifest supplies the retry policy, the backoff, and the error classification.

Every step reaches exactly one outcome, and implementations MUST record it.

Outcome Meaning
succeeded The body produced a result.
skipped The step’s when guard was false.
failed The body did not produce a result, and retry did not recover it.
not_run The step was never reached: it follows a stop, it is in a branch that did not execute, or it is in an iteration that stopped at an earlier step.

A reference to a step whose outcome is skipped, failed, or not_run resolves to null.

Every run reaches exactly one terminal outcome: completed, stopped, or failed. A run can also be suspended or held, which are not terminal. While a run is held for an ambiguous outcome, the step that caused the hold has not reached an outcome. The run record holds the attempt and its classified code.

The default. A step fails only after its retry policy is exhausted. A failed step then fails the run, unless a policy applies. Implementations MUST apply the policies in this order.

  1. must_succeed: true on the step. The run fails, and no enclosing policy applies.
  2. optional: true on the step. The failure is recorded, the run continues at the next step, and the failure does not count toward a stop_after threshold.
  3. The on_error of an enclosing for_each or parallel, where the step is inside one.
  4. The recovery of the nearest enclosing scope that declares one.
  5. Otherwise the run fails, with the outcome failed.

When a policy fails the enclosing construct, that construct is itself a failed step. Implementations MUST then apply this ladder again, at the construct’s own level.

Implementations MUST reject a step that declares both optional and must_succeed.

- lead:
crm.create_lead: { email: "{{ message.from }}" }
idempotency_key: "{{ message.id }}"
must_succeed: true
undo: { crm.delete_lead: { id: "{{ lead.id }}" } }
- notify:
slack.post: { channel: "#sales" }
optional: true

retry overrides the manifest’s default retry policy for one step. It takes the shape that the connector manifest defines. Implementations MUST NOT retry a failure that the manifest classifies fatal, and MUST NOT retry an evaluation fault.

idempotency_key is an expression that produces the value which the action’s declared idempotency parameter receives. run.id and a trigger payload identifier are correct components of such a key.

- triage:
route: "{{ classify.category }}"
recovery:
redo: this_and_after
stop_after: { failures: 3, within: 5m }
then: hold
notify: support-team
cases: { ... }

recovery appears on a scope-creating construct or at the top level of a file.

redo declares what is re-run when a step inside the scope fails:

Value Behavior
this_step The failed step only. Default.
this_and_after The failed step and its transitive downstream closure within the scope.
whole_group Every step in the scope.

A redo re-executes each member of the set. A re-executed step binds its newest result, and the run record keeps every attempt. Compensation before a redo is specified in undo.

stop_after bounds total failures within a rolling window across the scope. When the bound is exceeded, then applies.

Value Behavior
hold Suspend the run durably, for later replay.
fail Fail the run.
escalate Apply the policy of the enclosing scope.

then: hold requires the durable-timers capability.

stop_after reads a clock, so its evaluation is not pure. Implementations MUST record each stop_after evaluation and the decision that followed, and MUST replay the recorded decision rather than re-evaluate it.

notify is an opaque identifier that the deployment environment resolves to a notification target. This standard defines no vocabulary for it, and it does not affect evaluation.

undo declares a step’s compensating action.

Under redo: this_and_after or redo: whole_group, every step in the redo set MUST be naturally idempotent, or idempotent with a declared idempotency_key, or MUST include undo. Implementations MUST reject a file that violates this rule, and the diagnostic MUST name the offending step.

A redo is the one trigger of an undo. Before a redo, implementations MUST run the undo of every step in the redo set whose outcome is succeeded and that declares one. Compensations run in reverse completion order. An idempotent member needs no compensation: its redo re-issues the action, with the same key where the action takes one.

An undo is an action call. Its arguments are evaluated in the scope as it stands before the redo, so they can read the compensated step’s result. Implementations MUST record the invocation and its result against the step it compensates. A failed undo fails the run: the state of the effect is then unknown, and the record names the invocation that failed.

A run that fails runs no compensations. Its record holds every effect, and cleanup is a deployment decision.

An action’s outcome can be ambiguous: a timeout with no response, or a lost connection during a request. Such an outcome is neither success nor failure. on_unknown declares what happens.

Value Behavior
retry Re-issue the action. Permitted only when the manifest declares the action idempotent and, where the action declares an idempotency_param, the step supplies idempotency_key.
reconcile Run the declared reconcile steps to determine the true outcome.
halt Suspend the run durably for human resolution. The run is then held.

The default is determined statically:

  • retry, when the manifest declares the action idempotent and, where it declares an idempotency_param, the step supplies an idempotency_key;
  • halt, in every other case.
- refund:
billing.issue_refund: { ... }
idempotency_key: "{{ message.id }}"
on_unknown: reconcile
reconcile:
steps:
- check:
billing.find_refund: { external_id: "{{ message.id }}" }
optional: true
outcome: "{{ (check.status | default('absent')) == 'settled' }}"
result: { refund_id: "{{ check.refund_id }}" }

reconcile is a map with three keys.

  • steps is a step list. It runs in a scope that nests inside the step’s scope, so enclosing bindings are readable. Its ids are readable by outcome and result, and nowhere else.
  • outcome is a guard, evaluated in that scope after the steps complete. True means the action took effect.
  • result is a value-shaped structure, evaluated in the same scope when outcome is true. The step binds it. result is REQUIRED when the action declares an output, and implementations MUST validate the value against that schema before binding.

When outcome is true, the step succeeds and binds result. When outcome is false, the action did not take effect. Implementations MUST record the attempt as a failure with the classified code of the ambiguous outcome. Remaining retry attempts then apply, and re-issue is safe for any action, because reconciliation established that the action produced no effect.

A failed step inside steps, or a fault in outcome or in result, leaves the outcome ambiguous. Implementations MUST then apply halt. optional applies inside steps, so a probe that can fail composes with default in outcome, as the example shows.

Implementations MUST reject on_unknown: retry on an action that the manifest does not declare idempotent, or on a step that supplies no idempotency_key while the action declares an idempotency_param. Implementations MUST reject a reconcile key on a step whose on_unknown is not reconcile. Implementations MUST reject an on_unknown: reconcile step whose reconcile is absent, omits steps or outcome, or omits result while the action declares an output.