{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://actum.run/spec/composition.schema.json",
  "title": "Composition Definition",
  "description": "A declared pipeline of primitive API calls: typed input validated before any call fires, an ordered step sequence, a cherry-picked output projection, and a per-step undo for the failure contract. Deliberately contains no namespace field: the namespace is the resource path, and the same byte-identical artifact exists in multiple namespaces via promotion (content-hash verified). Connection names in steps resolve against the executing namespace's credentials.",
  "type": "object",
  "required": [
    "spec_version",
    "name",
    "version",
    "input",
    "steps",
    "output"
  ],
  "properties": {
    "spec_version": {
      "type": "string",
      "description": "Version of the Actum spec this document conforms to. Unrelated to the composition's own version.",
      "examples": [
        "0.2.0"
      ]
    },
    "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]*$",
      "description": "Identifier for the composition.",
      "examples": [
        "create_issue_with_labels"
      ]
    },
    "version": {
      "type": "string",
      "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-[0-9A-Za-z.-]+)?$",
      "description": "The composition's own semver, declared by the publisher. A name+version pair is immutable once published: re-publishing an existing version conflicts (409); any change to steps or undos requires at least a patch bump. Servers verify the declared bump against the input/output schema diff (breaking change without a major bump is rejected). Semver covers the interface; behavior changes within an identical interface are why a patch bump is still a new version. Pre-release versions (e.g. 1.2.0-beta.1) may exist only in the dev namespace and cannot be promoted; promotion to prod requires a release version.",
      "examples": [
        "1.0.0",
        "1.2.0-beta.1"
      ]
    },
    "description": {
      "type": "string",
      "description": "What this composition does, written for a caller that has no other context (an agent may have only this)."
    },
    "input": {
      "type": "object",
      "description": "JSON Schema for the composition's input. Validated in full before any step executes; no step may reference input not declared here.",
      "$comment": "Any valid JSON Schema object."
    },
    "steps": {
      "type": "array",
      "minItems": 1,
      "description": "Ordered sequence of primitive calls. Each maps 1:1 to a provider operation.",
      "items": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]*$"
          },
          "type": {
            "enum": [
              "request",
              "approval"
            ],
            "default": "request",
            "description": "Discriminates the step variant. Absent or 'request' (the default): today's provider-call step \u2014 requires connection/request/undo as before, unchanged. 'approval': an in-execution human approval gate \u2014 requires the approval block instead of connection/request/undo; the execution pauses (state awaiting_approval) until a principal holding the gate's approval.role decides it (see spec/access-control.md's approver role and Publication sign-off section). This field is purely additive: every step written before its introduction has no type, so it defaults to 'request' and validates unchanged."
          },
          "approval": {
            "type": "object",
            "description": "Present iff type is 'approval'. Declares who may decide the gate and what happens if nobody does in time. Rejection is reported through the ordinary failure contract, never a new top-level error: error stays the const compose_step_failed, with failed_step.provider_error.code set to 'approval_rejected' and (from a later story) an optional failed_step.gate object carrying the decision detail \u2014 the error enum itself is never extended for this.",
            "required": [
              "role",
              "timeout",
              "on_timeout"
            ],
            "properties": {
              "role": {
                "type": "string",
                "description": "Composition-level role (see spec/access-control.md Roles) that may decide this gate \u2014 typically approver, though a deployment may bind a different role to a given gate."
              },
              "four_eyes": {
                "type": "boolean",
                "default": false,
                "description": "When true, the deciding principal must differ from the execution's creator (created_by) \u2014 the same segregation-of-duties shape as a publication's four_eyes."
              },
              "timeout": {
                "type": "string",
                "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+S)?)?$",
                "description": "ISO-8601 duration, measured from the gate's arrival (when the execution enters awaiting_approval for this step). Evaluated lazily \u2014 on any read or decision after expiry, by timestamp, not by a delivered timer event.",
                "examples": [
                  "P3D",
                  "PT4H"
                ]
              },
              "on_timeout": {
                "enum": [
                  "reject",
                  "expire"
                ],
                "description": "What happens when timeout elapses with no decision. 'reject': the gate resolves as a rejection \u2014 the ordinary failure contract, retryable.resumable: false. 'expire': the gate fails as a resumable failure (retryable.resumable: true); resuming the execution re-arms the gate for a fresh decision window."
              }
            }
          },
          "connection": {
            "type": "string",
            "description": "Name of the connection (provider credential) this step uses. Steps in one composition may use different connections \u2014 that is the point. Required for a 'request' step (the default); absent on an 'approval' step."
          },
          "request": {
            "type": "object",
            "required": [
              "method",
              "path"
            ],
            "properties": {
              "method": {
                "enum": [
                  "GET",
                  "POST",
                  "PUT",
                  "PATCH",
                  "DELETE",
                  "HEAD"
                ]
              },
              "path": {
                "type": "string",
                "pattern": "^/",
                "description": "Path template, resolved against the step connection's base_url (credential confinement: the connection's credential can only ever travel to its own provider's host \u2014 by construction). May reference declared input as {{ input.x }} and prior step outputs as {{ steps.<name>.response.<path> }}, with optional filters; substitutions are percent-encoded by position. Grammar in templates.md."
              },
              "body": {
                "description": "Request body template: a JSON object or array whose values may be templates. Substitution is structural (a value that is entirely one template keeps its resolved type; strings containing templates interpolate scalars) \u2014 see templates.md. Arrays support JSON Patch style bodies."
              },
              "encoding": {
                "enum": [
                  "json",
                  "form"
                ],
                "default": "json",
                "description": "How the resolved body is sent on the wire. 'json' (default): Content-Type application/json, body serialized as JSON. 'form': Content-Type application/x-www-form-urlencoded, the resolved body flattened to indexed-bracket-notation key/value pairs (Stripe's v1 REST API convention \u2014 see templates.md for the exact flattening algorithm)."
              },
              "headers": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Header templates. Values may be a full-value {{ secrets.<name> }} placeholder (send-time injection); the connection's credential headers always override step headers."
              },
              "query": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Query parameter templates, appended to the request. Together with header values, the only place a {{ secrets.<name> }} placeholder may appear \u2014 and only as the entire value. Secret values come from the bound connection at send time and are never persisted, logged, or shown in plans or failure contracts; rotation between run and unwind is picked up automatically."
              }
            }
          },
          "read_only": {
            "type": "boolean",
            "default": false,
            "description": "Marks the step as a read. Read steps may be cached (default TTL 24h, force_refresh honored) and produce no unwind entries. Mutating steps are never cached."
          },
          "resources": {
            "type": "object",
            "description": "Resource identifiers this step produces, extracted from its response \u2014 these populate completed_steps in the failure contract.",
            "additionalProperties": {
              "type": "string",
              "description": "Template path into the step response, e.g. {{ response.id }}."
            }
          },
          "undo": {
            "description": "How to compensate this step if a later step fails. Required for mutating steps.",
            "oneOf": [
              {
                "const": "none",
                "$comment": "Declares the step uncompensatable (sent email, captured payment). Must be explicit, never implied."
              },
              {
                "type": "object",
                "required": [
                  "request",
                  "class"
                ],
                "properties": {
                  "request": {
                    "$ref": "#/properties/steps/items/properties/request"
                  },
                  "class": {
                    "enum": [
                      "always",
                      "on_abandon"
                    ],
                    "description": "always: must run even before a retry (locks, blocking half-writes). on_abandon: safe to keep on resume, orphaned on abandon."
                  },
                  "missing_ok": {
                    "type": "boolean",
                    "default": false,
                    "description": "0.14.0 (undo absent-is-success classification, plan D1): additive, opt-in per-undo declaration. When true, any undo attempt — the first attempt or a later re-drive — 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 (failure-contract.schema.json's $defs/unwind_entry.undo carries the plan-side re-drive prose; the wire discriminator that marks an inferred confirmation is execution.schema.json's compensation.confirmed[].outcome -- $defs/confirmedUndoEntry). This closes a defect first observed on re-drives — a 404/410 after a response-phase ambiguity was misclassified pending/unwind_incomplete — but the classification itself applies identically on a first attempt, which is the feature's most common real case. Default false: a 404/410 undo response is an ordinary non-confirmed outcome, exactly as before 0.14.0 — an existing composition with no missing_ok key changes no meaning. Only meaningful on a real undo request (never on undo: \"none\", which sends no request at all). Caution: missing_ok trusts the provider's 404/410 to mean the resource is gone; a broken undo URL or template that itself 404s is classified done instead of surfacing as a non-confirmed/pending outcome for the caller to investigate — authors opt in per undo step, accepting that tradeoff."
                  }
                }
              }
            ]
          },
          "idempotency_key": {
            "type": "object",
            "required": [
              "header"
            ],
            "additionalProperties": false,
            "description": "§A1 (unknown-outcome idempotency): declares the request header into which the engine injects a stable, deterministic per-(execution, step) idempotency key before sending this step. The value is byte-identical on the initial run and on every resume re-fire, so a provider that honors idempotency keys collapses a re-fired unknown-outcome (timeout-after-send) step to a single effect. Only meaningful on a mutating (non read_only) request step; absent means no key is injected.",
            "properties": {
              "header": {
                "type": "string",
                "minLength": 1,
                "description": "Header name that carries the idempotency key.",
                "examples": [
                  "Idempotency-Key"
                ]
              }
            }
          },
          "check": {
            "type": "object",
            "required": [
              "request"
            ],
            "additionalProperties": false,
            "description": "§A1 (unknown-outcome reconciliation): a side-effect-free read the engine may fire on resume of a step whose prior attempt ended with an unknown outcome (retryable.reconcile_required) to decide automatically whether the mutation already landed. A 2xx response means landed — the step is recorded done and never re-fired (its resources/undo are resolved from this read's response, so a landed step stays unwindable); a 404 means not landed — the check itself is the reconciliation, so resume may re-fire without an explicit caller ack; any other status (or no check, or a check error) is inconclusive and still requires an explicit ack before re-firing. The check request MUST use a safe method — it can race a concurrent resume before the single-driver CAS is acquired, so a mutating method would risk a double-mutation.",
            "properties": {
              "request": {
                "allOf": [
                  {
                    "$ref": "#/properties/steps/items/properties/request"
                  },
                  {
                    "properties": {
                      "method": {
                        "enum": [
                          "GET",
                          "HEAD"
                        ]
                      }
                    }
                  }
                ],
                "description": "The reconciliation read — same template grammar as a step request, restricted to GET/HEAD."
              }
            }
          }
        },
        "allOf": [
          {
            "if": {
              "properties": {
                "type": {
                  "not": {
                    "const": "approval"
                  }
                }
              }
            },
            "then": {
              "if": {
                "properties": {
                  "read_only": {
                    "const": false
                  }
                }
              },
              "then": {
                "required": [
                  "name",
                  "connection",
                  "request",
                  "undo"
                ]
              },
              "else": {
                "required": [
                  "name",
                  "connection",
                  "request"
                ]
              }
            }
          },
          {
            "if": {
              "properties": {
                "type": {
                  "const": "approval"
                }
              },
              "required": [
                "type"
              ]
            },
            "then": {
              "required": [
                "name",
                "approval"
              ],
              "not": {
                "anyOf": [
                  {
                    "required": [
                      "connection"
                    ]
                  },
                  {
                    "required": [
                      "request"
                    ]
                  },
                  {
                    "required": [
                      "undo"
                    ]
                  }
                ]
              }
            }
          }
        ]
      }
    },
    "output": {
      "type": "object",
      "description": "Cherry-picked output projection: stable field names mapped to templates (see templates.md). Return what callers need, not every intermediate response (raw step outputs available via the execution's details).",
      "additionalProperties": {
        "type": "string"
      },
      "examples": [
        {
          "issue_id": "{{ steps.create_issue.response.id }}",
          "url": "{{ steps.create_issue.response.html_url }}"
        }
      ]
    },
    "unwind_mode": {
      "enum": ["strict", "best_effort"],
      "description": "0.10.0 (preflight+unwind-policy epic, plan D4): declares how unwind() behaves when it hits a non-confirmed undo outcome (an unavailable dependency, a conclusive error, or an ambiguous/unknown response) partway through the reverse-order pass. Absent means strict -- MUST be explicit, never implied, the same rule undo: \"none\" enforces: strict halts the pass at the first non-confirmed outcome (execution.schema.json's state goes unwind_incomplete, retried by a later unwind() call) because a later undo may depend on an earlier one (in step order) having already run. best_effort is the composition author's a-priori assertion that this composition's compensations are independent of each other, so the engine may attempt every undo whose dependency is reachable even after an earlier one could not be confirmed; an unavailable undo is recorded in the execution's compensation.skipped bucket (reason + dependency) rather than halting the pass, and the execution can terminate partially_compensated. Execution create does NOT accept a mode override: independence is the definer's assertion, not a caller's -- a caller may never weaken a composition's declared unwind_mode (mirrors the preflight_probes strengthen-only rule below). A namespace profile's strict_unwind control (see profile.schema.json) may force strict behavior for a best_effort-declared composition in that namespace regardless of this field.",
      "default": "strict"
    },
    "preflight_probes": {
      "type": "boolean",
      "default": false,
      "description": "0.10.0 (preflight+unwind-policy epic, plan D8): when true, execution create additionally runs each connection's declared read-only probe (see spec/README.md's Connections section) with the namespace's bindings before step 1 fires -- a failed or unreachable probe refuses the create (503 preflight-failure.schema.json, kind: provider) with no execution resource created and no step side-effect. Default false: probing is opt-in, never universal -- some providers have no safe ping endpoint, and probe traffic spends rate-limit budget and can penalize certain auth schemes. Execution create may additionally request probes for one run as an opt-in strengthening (a caller may ask for more preflight than the composition declares, mirroring unwind_mode's caller-cannot-weaken rule); it can never disable a composition's own preflight_probes: true."
    }
  }
}