Failure and Recovery
Recovery is layered, and most files declare none. The connector manifest supplies the retry policy, the backoff, and the error classification.
Outcomes
Section titled “Outcomes”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.
must_succeed: trueon the step. The run fails, and no enclosing policy applies.optional: trueon the step. The failure is recorded, the run continues at the next step, and the failure does not count toward astop_afterthreshold.- The
on_errorof an enclosingfor_eachorparallel, where the step is inside one. - The
recoveryof the nearest enclosing scope that declares one. - 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.
Step-level policy
Section titled “Step-level policy” - 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: trueretry 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.
Scope-level recovery
Section titled “Scope-level recovery” - 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.
on_unknown
Section titled “on_unknown”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 anidempotency_param, the step supplies anidempotency_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.
stepsis a step list. It runs in a scope that nests inside the step’s scope, so enclosing bindings are readable. Its ids are readable byoutcomeandresult, and nowhere else.outcomeis a guard, evaluated in that scope after the steps complete. True means the action took effect.resultis avalue-shaped structure, evaluated in the same scope whenoutcomeis true. The step binds it.resultis REQUIRED when the action declares anoutput, 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.