{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://dispatched.work/schemas/workflow.schema.json",
  "title": "Dispatched Workflow",
  "description": "A Dispatched workflow definition. See https://dispatched.work/docs/workflows.",
  "type": "object",
  "required": ["name", "version", "steps"],
  "properties": {
    "$schema": {
      "type": "string",
      "description": "Optional schema URL for editor validation."
    },
    "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
      "description": "Lowercase kebab-case identifier (e.g. my-workflow)."
    },
    "version": {
      "type": "integer",
      "minimum": 1,
      "description": "Positive integer version number."
    },
    "schemas": {
      "type": "object",
      "description": "Named JSON Schema definitions referenced by steps and triggers.",
      "additionalProperties": { "$ref": "#/$defs/jsonSchema" }
    },
    "vars": {
      "type": "object",
      "description": "Workflow-level variables, resolved once at run start against { trigger, run, secrets } and then available to every expression as `vars.<name>`. Values may be scalars, `{{ }}`-templated strings, expression blocks ({\"expr\": \"…\", \"lang\": \"lua\"|\"jsonpath\"|unset}), or nested maps/lists. Vars cannot reference other vars or step outputs.",
      "additionalProperties": { "$ref": "#/$defs/varValue" }
    },
    "triggers": {
      "type": "array",
      "description": "How the workflow is started.",
      "items": { "$ref": "#/$defs/trigger" }
    },
    "steps": {
      "type": "object",
      "description": "Step definitions keyed by step id. Step ids must start with a letter and contain only letters, numbers, and underscores (no hyphens — they break expressions).",
      "propertyNames": {
        "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$"
      },
      "additionalProperties": { "$ref": "#/$defs/step" }
    },
    "response": { "$ref": "#/$defs/response" }
  },
  "additionalProperties": false,

  "$defs": {
    "trigger": {
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": {
          "type": "string",
          "enum": ["http", "schedule", "event"]
        },
        "schema": {
          "type": "string",
          "description": "Name of a JSON Schema defined under top-level `schemas` used to validate the trigger payload."
        },
        "when": {
          "description": "Condition evaluated against { trigger, secrets, vars }. The trigger only fires when this returns true. Accepted as either a bare string or a `{expr, lang}` expression block. Bare strings dispatch by prefix: anything starting with `return ` is Lua; everything else is treated as a path expression (e.g. `steps.foo.status == 200`). Expression blocks without `lang` also default to the path runtime.",
          "$ref": "#/$defs/expression"
        }
      },
      "allOf": [
        {
          "if": { "properties": { "type": { "const": "schedule" } }, "required": ["type"] },
          "then": {
            "required": ["schedule"],
            "properties": {
              "schedule": {
                "type": "string",
                "description": "Cron expression (5 fields: minute hour day month weekday)."
              }
            }
          }
        }
      ]
    },

    "step": {
      "type": "object",
      "properties": {
        "after": {
          "type": "array",
          "description": "Step ids this step depends on. Forms the DAG.",
          "items": { "type": "string" },
          "uniqueItems": true
        },
        "when": {
          "description": "Condition evaluated against the run context. The step only runs when this returns true. Accepted as either a bare string or a `{expr, lang}` expression block. Bare strings dispatch by prefix: anything starting with `return ` is Lua; everything else is treated as a path expression (e.g. `steps.foo.status == 200`). Expression blocks without `lang` also default to the path runtime.",
          "$ref": "#/$defs/expression"
        },
        "request": { "$ref": "#/$defs/httpRequest" },
        "response": { "$ref": "#/$defs/httpResponse" },
        "retry": { "$ref": "#/$defs/retryPolicy" },
        "compensate": {
          "description": "HTTP request executed to roll back this step if a later step fails (saga pattern).",
          "$ref": "#/$defs/httpRequest"
        },
        "strategy": { "$ref": "#/$defs/strategy" },
        "wait": { "$ref": "#/$defs/waitConfig" },
        "directives": {
          "type": "array",
          "items": { "type": "string" }
        }
      },
      "additionalProperties": false
    },

    "httpRequest": {
      "type": "object",
      "required": ["url"],
      "properties": {
        "method": {
          "type": "string",
          "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
          "default": "POST"
        },
        "url": {
          "type": "string",
          "description": "URL template. Supports {{ expr }} interpolation."
        },
        "headers": {
          "type": "object",
          "additionalProperties": { "type": "string" }
        },
        "body": {
          "description": "Request body. A string is sent as the raw body (e.g. form-urlencoded text); an object or array is serialized as JSON. `{{ }}` interpolation is applied to string leaves recursively.",
          "$ref": "#/$defs/renderable"
        },
        "schema": {
          "description": "Inline JSON Schema (2020-12) used to validate the rendered request body.",
          "$ref": "#/$defs/jsonSchema"
        }
      },
      "additionalProperties": false
    },

    "httpResponse": {
      "type": "object",
      "properties": {
        "timeout": {
          "type": "integer",
          "minimum": 1,
          "description": "Request timeout in milliseconds."
        },
        "expect": {
          "type": "object",
          "description": "Assertions the response must satisfy. Today only `status` is checked at runtime; future keys may be added.",
          "properties": {
            "status": {
              "description": "HTTP status code(s) considered successful for this step.",
              "oneOf": [
                { "type": "integer", "minimum": 100, "maximum": 599 },
                {
                  "type": "array",
                  "items": { "type": "integer", "minimum": 100, "maximum": 599 },
                  "minItems": 1,
                  "uniqueItems": true
                }
              ]
            }
          },
          "additionalProperties": false
        },
        "store": {
          "type": "string",
          "description": "Variable name under which to store the response."
        },
        "schema": {
          "description": "Inline JSON Schema (2020-12) used to validate the response body. Can reference top-level schemas via `{\"$ref\": \"#/schemas/<name>\"}`.",
          "$ref": "#/$defs/jsonSchema"
        }
      },
      "additionalProperties": false
    },

    "retryPolicy": {
      "type": "object",
      "properties": {
        "on": {
          "type": "array",
          "description": "HTTP status codes that trigger a retry.",
          "items": { "type": "integer", "minimum": 100, "maximum": 599 },
          "default": [500, 502, 503, 504]
        },
        "max": {
          "type": "integer",
          "minimum": 1,
          "default": 3
        },
        "backoff": { "$ref": "#/$defs/backoff" }
      },
      "additionalProperties": false
    },

    "backoff": {
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": {
          "type": "string",
          "enum": ["fixed", "linear", "exponential"],
          "description": "fixed = constant delay; linear = base × attempt; exponential = base × 2^(attempt-1)."
        },
        "base": {
          "type": "integer",
          "minimum": 1,
          "description": "Base delay in milliseconds."
        }
      },
      "additionalProperties": false
    },

    "strategy": {
      "oneOf": [
        { "$ref": "#/$defs/strategyFanOut" },
        { "$ref": "#/$defs/strategyBatch" },
        { "$ref": "#/$defs/strategyRace" },
        { "$ref": "#/$defs/strategyScatter" }
      ]
    },

    "strategyFanOut": {
      "type": "object",
      "required": ["type", "over"],
      "properties": {
        "type": { "const": "fan_out" },
        "over": {
          "type": "string",
          "description": "Path expression to the array to iterate over."
        },
        "as": { "$ref": "#/$defs/typedBinding" },
        "concurrency": { "type": "integer", "minimum": 1, "default": 10 },
        "join": { "$ref": "#/$defs/join" },
        "reduce": { "$ref": "#/$defs/reduce" }
      },
      "additionalProperties": false
    },

    "strategyBatch": {
      "type": "object",
      "required": ["type", "over", "size"],
      "properties": {
        "type": { "const": "batch" },
        "over": { "type": "string" },
        "as": { "$ref": "#/$defs/typedBinding" },
        "size": { "type": "integer", "minimum": 1 },
        "concurrency": { "type": "integer", "minimum": 1, "default": 1 }
      },
      "additionalProperties": false
    },

    "strategyRace": {
      "type": "object",
      "required": ["type", "over"],
      "properties": {
        "type": { "const": "race" },
        "over": { "type": "string" },
        "as": { "$ref": "#/$defs/typedBinding" },
        "concurrency": { "type": "integer", "minimum": 1 },
        "on_first_success": { "type": "string" }
      },
      "additionalProperties": false
    },

    "strategyScatter": {
      "type": "object",
      "required": ["type", "over"],
      "properties": {
        "type": { "const": "scatter" },
        "over": {
          "type": "object",
          "description": "Map of branch name → per-branch data. Each value is dispatched in parallel using the step's shared `request`; the branch data is bound as the loop variable (`{{ item.* }}` by default, or the name from `as`).",
          "additionalProperties": true
        },
        "on_partial": {
          "type": "string",
          "enum": ["continue", "fail"],
          "default": "fail"
        }
      },
      "additionalProperties": false
    },

    "typedBinding": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string" },
        "schema": { "$ref": "#/$defs/jsonSchema" }
      },
      "additionalProperties": false
    },

    "join": {
      "type": "object",
      "properties": {
        "mode": {
          "type": "string",
          "enum": ["all", "any", "n_of", "first"],
          "default": "all"
        },
        "min_success": {
          "type": "number",
          "description": "Required when mode is \"n_of\"."
        }
      },
      "additionalProperties": false
    },

    "reduce": {
      "type": "object",
      "required": ["as", "init"],
      "properties": {
        "as": {
          "type": "string",
          "description": "Accumulator variable name."
        },
        "init": {
          "description": "Initial accumulator value."
        },
        "on_result": { "$ref": "#/$defs/exprBlock" }
      },
      "additionalProperties": false
    },

    "exprBlock": {
      "type": "object",
      "required": ["expr"],
      "properties": {
        "lang": {
          "type": ["string", "null"],
          "enum": ["lua", "path", "jsonpath", null],
          "description": "Expression language. Defaults to `path` when omitted."
        },
        "expr": { "type": "string" },
        "output_schema": { "$ref": "#/$defs/jsonSchema" }
      },
      "additionalProperties": false
    },

    "expression": {
      "description": "An expression that resolves to a primitive value (string, number, boolean, or null). Written either as a string (optionally containing `{{ }}` interpolation) or a structured expression block with an explicit language. Structural constraint only — schema validators can't enforce the primitive return type, so authors are responsible for writing expressions that don't return arrays or objects.",
      "oneOf": [
        { "type": "string" },
        { "$ref": "#/$defs/exprBlock" }
      ]
    },

    "jsonSchema": {
      "description": "Any valid JSON Schema 2020-12 document. Delegated to the official meta-schema so editors get full validation and completion on inline schema objects (top-level `schemas`, `httpRequest.schema`, `httpResponse.schema`, `typedBinding.schema`, `exprBlock.output_schema`).",
      "$ref": "https://json-schema.org/draft/2020-12/schema"
    },

    "renderable": {
      "description": "An author-written value rendered through `{{ }}` interpolation at request time. A bare string is sent as the raw body (plain text, form-urlencoded, XML, …); arrays and objects are serialized as JSON with their string leaves interpolated recursively.",
      "oneOf": [
        { "type": ["string", "number", "boolean", "null"] },
        { "type": "array", "items": { "$ref": "#/$defs/renderable" } },
        { "type": "object", "additionalProperties": { "$ref": "#/$defs/renderable" } }
      ]
    },

    "varValue": {
      "description": "A workflow var value. Scalars pass through; strings may contain `{{ }}` interpolation; objects with an `expr` string key are treated as expression blocks (`{\"expr\": \"…\", \"lang\": \"lua\"|\"jsonpath\"|unset}`); other objects and arrays are recursed.",
      "oneOf": [
        { "type": ["number", "boolean", "null"] },
        { "$ref": "#/$defs/expression" },
        {
          "type": "array",
          "items": { "$ref": "#/$defs/varValue" }
        },
        {
          "type": "object",
          "not": { "required": ["expr"] },
          "additionalProperties": { "$ref": "#/$defs/varValue" }
        }
      ]
    },

    "waitConfig": {
      "type": "object",
      "properties": {
        "signal": {
          "type": "string",
          "description": "Named signal to wait for."
        },
        "any": {
          "type": "array",
          "items": { "type": "string" }
        },
        "all": {
          "type": "array",
          "items": { "type": "string" }
        },
        "timeout": {
          "type": "string",
          "pattern": "^[0-9]+(ms|s|m|h|d)$",
          "description": "Duration string like \"30m\", \"24h\", \"5s\"."
        },
        "on_timeout": {
          "type": "string",
          "enum": ["continue", "reject", "cancel"]
        }
      },
      "additionalProperties": false,
      "dependentRequired": {
        "timeout": ["on_timeout"]
      }
    },

    "response": {
      "type": "object",
      "description": "Final HTTP response returned to the trigger caller. When omitted the trigger responds 201 Created with a Location header.",
      "properties": {
        "status": {
          "oneOf": [
            { "type": "integer", "minimum": 100, "maximum": 599 },
            { "type": "string", "description": "{{ expr }} returning an integer." }
          ]
        },
        "headers": {
          "type": "object",
          "additionalProperties": { "type": "string" }
        },
        "body": {
          "description": "Response body. A string is returned as the raw body; an object or array is serialized as JSON. `{{ }}` interpolation is applied to string leaves recursively.",
          "$ref": "#/$defs/renderable"
        }
      },
      "additionalProperties": false
    }
  }
}
