{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://actum.run/spec/failure-contract.schema.json",
  "title": "Compose Operation Failure Contract",
  "description": "Structured failure returned when a composition execution fails at a step. Gives the caller everything needed to choose between resume and abandon: what ran, what it created, what must be cleaned regardless, and what becomes orphaned only on abandon.",
  "type": "object",
  "required": [
    "spec_version",
    "error",
    "operation",
    "execution_id",
    "namespace",
    "failed_step",
    "completed_steps",
    "retryable",
    "unwind_always",
    "unwind_on_abandon"
  ],
  "properties": {
    "spec_version": {
      "type": "string",
      "description": "Version of this contract schema (semver).",
      "examples": [
        "0.2.0"
      ]
    },
    "error": {
      "const": "compose_step_failed"
    },
    "operation": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]*@.+$",
      "description": "The pinned composition that failed, as name@version \u2014 the unwind plans below were declared by exactly this version.",
      "examples": [
        "create_issue_with_labels@1.2.0"
      ]
    },
    "execution_id": {
      "type": "string",
      "description": "The execution resource this failure belongs to."
    },
    "namespace": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_-]{0,31}$",
      "description": "Namespace the execution ran in (in 0.x, 'dev' or 'prod'). Undo calls in the unwind plans must be executed with this namespace's connection bindings \u2014 an undo must hit the same system the do hit.",
      "examples": ["dev", "prod"]
    },
    "failed_step": {
      "type": "object",
      "required": [
        "index",
        "name",
        "attempted",
        "provider_error"
      ],
      "properties": {
        "index": {
          "type": "integer",
          "minimum": 1,
          "description": "1-based position of the failed step in the sequence."
        },
        "name": {
          "type": "string"
        },
        "attempted": {
          "type": "string",
          "description": "Human-readable description of the exact call attempted (method, endpoint, key arguments)."
        },
        "outcome": {
          "enum": ["failed", "unknown"],
          "default": "failed",
          "description": "Whether the provider's disposition of this step is conclusively known. 'failed' (the default; absence means this): a conclusive negative response (4xx/5xx) or a pre-send error (template resolution, secret injection, connection lookup) proves the call did not take effect. 'unknown': the request left the client on a mutating (non read_only) step and the transport dropped or timed out afterward — the provider may or may not have applied it; provider_error.status is therefore absent or unreliable. A read-only step's timeout is always reported as 'failed' (a re-read is always safe) — see retryable.reconcile_required, which an 'unknown' outcome always sets to true."
        },
        "provider_error": {
          "type": "object",
          "description": "The provider's underlying error, surfaced rather than wrapped.",
          "required": [
            "message"
          ],
          "properties": {
            "status": {
              "type": "integer"
            },
            "code": {
              "type": "string"
            },
            "message": {
              "type": "string"
            }
          }
        },
        "gate": {
          "type": "object",
          "description": "Present only when the failed step is a type:approval gate. Rejection is an ordinary failure at the gate step, never a new top-level error: error stays the const compose_step_failed and provider_error.code is 'approval_rejected' (an on_timeout:reject expiry uses the same code); this object carries the decision detail the ordinary provider_error shape has no field for.",
          "required": [
            "decision",
            "decided_at"
          ],
          "properties": {
            "decision": {
              "enum": ["rejected", "expired"],
              "description": "rejected: an explicit reject decision. expired: on_timeout:expire elapsed with no decision (this failure is resumable — see retryable.resumable — and resuming re-arms the gate; a plain on_timeout:reject expiry is reported as decision:rejected, decided_by absent)."
            },
            "decided_by": {
              "type": "string",
              "description": "The principal that rejected the gate. Absent for an on_timeout expiry (nobody decided) and under local single-trusted-caller mode (decided_by is null there even for an explicit decision, the same stance the audit actor field takes)."
            },
            "decided_at": {
              "type": "string",
              "format": "date-time",
              "description": "When the rejection or expiry was recorded."
            },
            "reason": {
              "type": "string",
              "description": "The decider's note. Required on an explicit reject decision; absent on a timeout expiry (nobody supplied one)."
            }
          }
        }
      }
    },
    "completed_steps": {
      "type": "array",
      "description": "Steps that completed before the failure, in execution order, with the resource identifiers they produced.",
      "items": {
        "type": "object",
        "required": [
          "index",
          "name",
          "resources"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "minimum": 1
          },
          "name": {
            "type": "string"
          },
          "resources": {
            "type": "object",
            "description": "Identifiers of resources this step created or modified, keyed by a stable name.",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      }
    },
    "retryable": {
      "type": "object",
      "required": [
        "resumable"
      ],
      "properties": {
        "resumable": {
          "type": "boolean",
          "description": "Whether the execution can be resumed. Resumption continues from resume_from and never re-runs completed steps."
        },
        "resume_from": {
          "type": "integer",
          "minimum": 1,
          "description": "Step index resumption would start from. Required when resumable is true."
        },
        "reason": {
          "type": "string",
          "description": "When not resumable, why (e.g. the failure invalidated earlier steps' state)."
        },
        "reconcile_required": {
          "type": "boolean",
          "default": false,
          "description": "True iff failed_step.outcome is 'unknown': the caller MUST NOT rely on a plain resume to blindly re-fire the failed step. Reconciliation is either an explicit ack/reconcile input on the resume call, or (when the composition step declares a read-based check) an automatic re-check the server performs before deciding whether to re-fire. Absent or false: an ordinary resumable failure, safe to re-fire as-is."
        },
        "retry_after": {
          "type": ["string", "integer"],
          "description": "When the caller should retry, if the provider indicated one (e.g. a 429 response's Retry-After header). A string is an RFC 3339 date-time (absolute); an integer is a delay in seconds (relative, from decided_at/now). Reserved slot: population is minimal today (429 Retry-After only) and this field's absence never implies immediate retry is safe — consult resumable/reconcile_required first.",
          "examples": ["2026-07-10T12:05:00Z", 30]
        }
      },
      "if": {
        "properties": {
          "resumable": {
            "const": true
          }
        }
      },
      "then": {
        "required": [
          "resumable",
          "resume_from"
        ]
      }
    },
    "unwind_always": {
      "type": "array",
      "description": "State that must be removed or reverted regardless of whether the caller retries \u2014 anything left broken, conflicting, or blocking (locks, half-written staging records). Execute these before any resume.",
      "items": {
        "$ref": "#/$defs/unwind_entry"
      }
    },
    "unwind_on_abandon": {
      "type": "array",
      "description": "Resources safe to keep if resuming (a resume picks them up), but orphaned if the caller gives up. Execute these only when abandoning.",
      "items": {
        "$ref": "#/$defs/unwind_entry"
      }
    }
  },
  "$defs": {
    "unwind_entry": {
      "type": "object",
      "required": [
        "resource",
        "undo"
      ],
      "properties": {
        "resource": {
          "type": "string",
          "description": "Human-readable identification of the resource/state, including its identifier.",
          "examples": [
            "issue 8841"
          ]
        },
        "undo": {
          "type": "string",
          "description": "The primitive call that undoes it, executable by the caller with the same connections. The literal string \"none\" declares the action cannot be compensated (e.g. a sent email) \u2014 implementations must surface, never hide, uncompensatable steps. 0.14.0 (undo absent-is-success classification, plan D1): when the composition step's undo declares missing_ok: true (composition.schema.json), any attempt at this call \u2014 the first attempt or a later re-drive \u2014 that receives HTTP 404 or 410 is classified absent-is-success: the resource is verifiably gone, so the undo is confirmed (undo_done) rather than left in a non-confirmed/pending outcome. (This closes a defect first observed on re-drives \u2014 a 404/410 after a response-phase ambiguity was misclassified pending/unwind_incomplete \u2014 but the classification is identical on a first attempt.) Default (missing_ok absent): a 404/410 response remains an ordinary non-confirmed outcome, exactly as before 0.14.0. This field is plan-side prose only (populated before any undo attempt); the wire discriminator lives in execution.schema.json: when the classification is inferred this way, the corresponding compensation.confirmed[] entry ($defs/confirmedUndoEntry) carries outcome: \"absent\" (as opposed to \"success\"/absent for an observed success response), distinguishing an inferred confirmation from an observed one. Caution: missing_ok trusts the provider's 404/410 to mean the resource is gone; a broken undo URL/template that itself 404s is classified done rather than surfaced as non-confirmed/pending.",
          "examples": [
            "DELETE /issues/8841",
            "none"
          ]
        }
      }
    }
  }
}