openapi: 3.0.3
info:
  title: Cascades API
  version: "1.0.0"
  description: |
    Public REST + webhook surface for triggering cascades, reading execution
    state, and subscribing to lifecycle events.

    This document is the single source of truth for the reference at
    [/docs](/docs): every operation carries an `operationId` (the slug of its
    reference page), the scopes it enforces (`x-scopes`), whether it is
    idempotent (`x-idempotent`), how to poll it when it is asynchronous
    (`x-async`), and — for the nine operations the MCP server wraps — the
    agent-facing tool definition (`x-mcp`). Webhook events you RECEIVE are
    catalogued under the root `x-webhooks` key.

    ## Recommended integration order

    1. `POST /webhook_subscriptions` — subscribe your endpoint to lifecycle
       events. Store the `signing_secret` from the response; it is shown
       exactly once.
    2. `POST /workflows/:id/trigger` — start a run with a unique
       `Idempotency-Key` and your `external_reference`.
    3. `GET /executions/:id` — poll until `status` leaves
       `pending` / `running` / `waiting`. Cascades is asynchronous;
       `trigger` returns `202 Accepted`, not the final state.
    4. Receive signed events at your subscriber URL: `adverse_action.*`
       (triggered / completed / failed / retry_scheduled) and `execution.*`
       (completed / failed). Verify `X-Cascades-Signature` before trusting
       the payload.
    5. (If the workflow includes Human Review) `POST /executions/:id/review_decisions`
       to approve or deny a paused execution.

    ## Authentication

    Every request requires a bearer token. Click **Authorize** above and
    paste your token. Tokens are issued per `ApiClient` and carry a
    scope vocabulary: `workflows:read`, `workflows:write`,
    `workflow_templates:read`, `executions:read`, `executions:read:full`,
    `executions:write`, `webhooks:read`, `webhooks:write`. Each operation
    below lists the scope it enforces; a token without it receives
    `403 insufficient_scope`. See
    [/docs/concepts/scopes-and-keys](/docs/concepts/scopes-and-keys).

    ## Errors

    Every non-2xx response is a JSON envelope:
    `{ "error": { "code", "message", "details": [], "request_id", "doc_url" } }`.
    `code` is a stable machine-readable string; `doc_url` links to the
    reference page for that code at `/docs/api/errors/<code>`.

    ## Rate limits

    - **60 req/min per source IP** on `/api/v1/*`
    - **600 req/hour per token prefix** (first 8 chars of bearer)
    - **300 req/5min per IP** globally

    A throttled request receives `429` with the standard JSON error
    envelope (`code: rate_limited`) and a `Retry-After` header giving the
    number of seconds until the window resets. Honour `Retry-After`; when
    it is absent back off exponentially with a minimum 1-second wait.

    ## Receiving webhooks

    Cascades POSTs lifecycle events to each active subscription whose
    `event_filter` matches (an empty filter receives every event). The
    body is a `WebhookEnvelope`: `{ "id", "event", "created_at", "data" }`,
    where `id` is a UUID shared by every subscriber's copy of the same
    event (use it to de-duplicate). Per-event `data` shapes are documented
    under `x-webhooks` below.

    **Headers on every delivery**

    - `X-Cascades-Event` — the event name, e.g. `execution.completed`
    - `X-Cascades-Delivery` — the `WebhookDelivery` id (see `GET /webhook_deliveries/{id}`)
    - `X-Cascades-Signature` — `t=<unix timestamp>,v1=<hex hmac>[,v0=<hex hmac>]`
    - `User-Agent: Cascades-Webhook/1.0`

    **Signature scheme.** `v1` is `HMAC-SHA256(signing_secret, "<t>.<raw body>")`
    encoded as lowercase hex, where `<t>` is the integer timestamp from the
    same header and `<raw body>` is the exact request body bytes (do not
    re-serialise the JSON before hashing). Compare with a constant-time
    equality check and reject timestamps older than a few minutes to defeat
    replay. After `POST /webhook_subscriptions/{id}/rotate_secret` the
    previous secret keeps signing for a **24-hour grace window**: during
    that window deliveries carry both `v1=` (new secret) and `v0=` (previous
    secret), so verify against whichever secret you currently hold.

    **Retries.** A delivery counts as successful on any 2xx response
    (5 s connect / 10 s read timeout). Anything else is retried on a fixed
    schedule of **60 s, 300 s, 1 800 s, 7 200 s, 28 800 s, 86 400 s**
    (1 m, 5 m, 30 m, 2 h, 8 h, 24 h) — six attempts in total — after which
    the delivery is marked `dead`. Dead and failed deliveries can be
    re-queued manually with `POST /webhook_deliveries/{id}/retry`.
    Subscriber URLs must be publicly routable (no private, loopback or
    cloud-metadata addresses); the check runs at save time and again at
    delivery time.

    ## Try-it-now safety

    Try-it-now requests hit **whichever environment served this
    docs page** — same origin, no dropdown to mismatch. In any
    environment Cascades runs, vendor calls (Equifax, LexisNexis)
    are mocked at the deployment level (`CRS_EXECUTION_MODE=mock`),
    so triggering a workflow does NOT pull a real credit report.
    **However:** mailer nodes (`SendAdverseAction`, `SendEmail`),
    Slack notifications, and outbound `Webhook` action nodes do
    execute for real, and lifecycle webhooks (`adverse_action.*`,
    `execution.*`) are POSTed to subscriber URLs as configured.
    Use a workflow whose recipients and webhook targets you control
    during a demo click-through. (In development, mailers go to
    letter_opener at `/letter_opener` instead of SMTP.)

servers:
  - url: /api/v1
    description: Current host

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: uuid

  parameters:
    PageParam:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, default: 1, minimum: 1 }
    PerPageParam:
      name: per_page
      in: query
      description: Items per page. Values above 100 are clamped to 100.
      schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 200 }
      example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
      description: |
        Unique per-business-event key. Identical resubmissions return
        the original response; conflicting bodies return `409
        idempotency_conflict`. Concurrent identical requests are
        race-safe. A missing or blank header returns
        `422 missing_idempotency_key`.

        The header is accepted up to 200 characters, but the stored key is
        namespaced to your client (`api_client:<id>:<key>`, and for
        re-evaluations `api_client:<id>:reeval:<execution_id>:<key>`) and
        the stored value is capped at 200 characters — so the **effective
        limit on `trigger` is about 185 characters** and lower still on
        `re_evaluations`. Use a UUID (36 characters) or another key of at
        most 128 characters to stay clear of the cap.
    WorkflowIdParam:
      name: id
      in: path
      required: true
      description: Workflow id. Only workflows owned by the token's account resolve; a foreign or deleted id returns `404`.
      schema: { type: integer }
    ExecutionIdParam:
      name: id
      in: path
      required: true
      description: Execution id. Scoped to the calling api_client; a foreign id returns `404`.
      schema: { type: integer }
    BatchIdParam:
      name: id
      in: path
      required: true
      description: Batch id. Scoped to the calling api_client; a foreign id returns `404`.
      schema: { type: integer }
    ArtifactIdParam:
      name: artifact_id
      in: path
      required: true
      description: Artifact id as returned by the matching list endpoint.
      schema: { type: integer }
    WebhookSubscriptionIdParam:
      name: id
      in: path
      required: true
      description: Webhook subscription id. Scoped to the calling api_client.
      schema: { type: integer }
    WebhookDeliveryIdParam:
      name: id
      in: path
      required: true
      description: Webhook delivery id. Scoped to the calling api_client's subscriptions.
      schema: { type: integer }

  schemas:
    Meta:
      type: object
      properties:
        request_id:
          type: string
          example: "7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f"
          description: Correlation id for this request. Also sent as the `X-Request-Id` response header; quote it when contacting support.
      required: [request_id]

    PaginatedMeta:
      allOf:
        - $ref: "#/components/schemas/Meta"
        - type: object
          properties:
            page: { type: integer, example: 1 }
            per_page: { type: integer, example: 25 }
            total_count: { type: integer, example: 142 }
            total_pages: { type: integer, example: 6 }
            object: { type: string, example: "execution", description: The object type of every item in `data`. }
          required: [page, per_page, total_count, total_pages, object]

    StaticCollectionMeta:
      description: |
        Meta for collections that are NOT paginated (artifact lists). The
        whole collection is always returned in one response and the
        `page` / `per_page` query parameters are ignored. The pagination
        fields are still present for shape compatibility but hold constant
        values: `page` is always 1, `total_pages` is always 1, and
        `per_page` equals `total_count`.
      allOf:
        - $ref: "#/components/schemas/Meta"
        - type: object
          properties:
            object: { type: string, example: "report_artifact" }
            total_count: { type: integer, example: 2, description: Number of items in `data`. }
            page: { type: integer, enum: [1], description: Always 1. }
            per_page: { type: integer, description: Always equal to `total_count`. }
            total_pages: { type: integer, enum: [1], description: Always 1. }
          required: [object, total_count]

    ErrorEnvelope:
      type: object
      description: The envelope returned by every non-2xx response, including `429` and the `/api/*` 404 catch-all.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: "validation_failed"
              description: Stable machine-readable error code. Branch on this, never on `message`.
            message: { type: string, example: "The request could not be processed." }
            details:
              type: array
              description: Field-level detail when available; otherwise an empty array.
              items:
                type: object
                properties:
                  field: { type: string, example: "execution_mode" }
                  message: { type: string, example: "must be test or live" }
            request_id: { type: string, example: "7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f" }
            doc_url:
              type: string
              format: uri
              description: Link to the reference page for this error code
              example: "https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed"
          required: [code, message, request_id]
      required: [error]

    # --- Cascade definition grammar ------------------------------------------
    # Mirrors Cascades::Grammar (node types, action subtypes, operators, edge
    # keys, required keys per type) and Cascades::DefinitionValidator, which
    # is the same vocabulary the AI builder emits and the engine executes.

    CascadeDefinition:
      type: array
      description: |
        A cascade's node graph: an ordered array of `CascadeNode` objects.
        Exactly one `ApplicantSource` node is the entry point; every other
        node is reached through the edge keys (`next_step`, `pass_steps`,
        `human_review_steps`, `fail_steps`), each of which is an array of
        node `id`s that must resolve within the same array. An empty array
        is accepted on create/update (an empty draft) but cannot be run.
      items: { $ref: "#/components/schemas/CascadeNode" }
      example:
        - id: "step_applicant"
          type: "ApplicantSource"
          name: "Applicant"
          next_step: ["step_credit"]
        - id: "step_credit"
          type: "BillingProduct"
          name: "Credit report"
          billing_product_code: "EQF1003"
          next_step: ["step_score_check"]
        - id: "step_score_check"
          type: "IfElse"
          name: "Score at least 650?"
          branches:
            pass:
              group_logic: "and"
              condition_groups:
                - logic: "and"
                  conditions:
                    - source_node_id: "step_credit"
                      field: "creditScore.fico9"
                      operator: "greater_than_or_equal"
                      value: 650
          pass_steps: ["step_approve"]
          fail_steps: ["step_adverse_action"]
        - id: "step_approve"
          type: "Action"
          name: "Approve"
          action_type: "Approve"
        - id: "step_adverse_action"
          type: "Action"
          name: "Adverse action"
          action_type: "AdverseAction"
          adverse_action:
            recipient: "{{applicant.email}}"
            action_taken: "denied"
            send_strategy: "draft_first"
            reason: "Credit score below the minimum required for this property."

    CascadeNode:
      type: object
      description: |
        One node of a cascade. `id` and `type` are always required; the
        remaining required keys depend on `type` (see `x-required-keys-by-type`):

        | type | required keys | purpose |
        |---|---|---|
        | `ApplicantSource` | — | Entry point; exposes the trigger payload as `context.applicant`. Exactly one per cascade. |
        | `BillingProduct` (alias `Product`) | `billing_product_code` | Pulls a report (credit, criminal, eviction, identity, income). |
        | `IfElse` (alias `Condition`) | `branches` | Evaluates conditions and routes to `pass_steps`, `human_review_steps` or `fail_steps`. |
        | `Wait` | `wait` | Pauses for `wait.duration` × `wait.unit`. |
        | `GoTo` | `target_step` | Runtime jump to another node (not a graph edge). |
        | `ShowResults` | — | Terminal node that surfaces collected outputs. |
        | `Action` | `action_type` | `Approve`, `SendEmail` (reads `email`), `AdverseAction` (reads `adverse_action`), `InternalNotification` (reads `internal_notification`). |

        `Product` and `Condition` are legacy aliases the engine still resolves;
        new definitions should use `BillingProduct` and `IfElse`. Unknown
        keys are tolerated and ignored by the engine.
      required: [id, type]
      properties:
        id:
          type: string
          description: Unique within the definition, e.g. `step_credit`. Edge keys reference nodes by this value.
          example: "step_credit"
        type:
          type: string
          enum: [ApplicantSource, BillingProduct, Product, IfElse, Condition, Wait, GoTo, ShowResults, Action]
        name:
          type: string
          description: Human-readable label shown in the builder and in `step_outputs`.
          example: "Credit report"
        next_step:
          type: array
          description: Default forward edge — ids of the node(s) to run next.
          items: { type: string }
        billing_product_code:
          type: string
          description: "`BillingProduct` / `Product` only. The product to pull, e.g. `EQF1003` (Equifax credit), `TUC1001` (TransUnion credit), `CIC4002` (criminal), `CIC4003` (eviction). May also be nested as `config.billing_product_code`."
          example: "EQF1003"
        config:
          type: object
          description: Optional per-node configuration. `store_result_as` names the key under which a product's output is stored.
          properties:
            store_result_as: { type: string }
            billing_product_code: { type: string, description: Alternative location for `billing_product_code`. }
          additionalProperties: true
        branches:
          type: object
          description: "`IfElse` / `Condition` only. Conditions for the `pass` branch and (optionally) the `human_review` branch; anything that matches neither takes `fail_steps`."
          properties:
            pass: { $ref: "#/components/schemas/CascadeBranch" }
            human_review: { $ref: "#/components/schemas/CascadeBranch" }
        pass_steps:
          type: array
          description: "`IfElse` only. Node ids to run when the `pass` branch matches."
          items: { type: string }
        human_review_steps:
          type: array
          description: "`IfElse` only. Node ids to run when the `human_review` branch matches; the execution pauses with `needs_human_review: true` until a review decision resumes it."
          items: { type: string }
        fail_steps:
          type: array
          description: "`IfElse` only. Node ids to run when no branch matches."
          items: { type: string }
        action_type:
          type: string
          description: "`Action` only."
          enum: [Approve, SendEmail, AdverseAction, InternalNotification]
        email:
          type: object
          description: "`Action` with `action_type: SendEmail`. Fields support `{{variable}}` interpolation from the execution context."
          properties:
            to: { type: string }
            subject: { type: string }
            body: { type: string }
          additionalProperties: true
        adverse_action:
          type: object
          description: "`Action` with `action_type: AdverseAction`. `send_strategy: draft_first` parks the notice for human review before sending."
          properties:
            recipient: { type: string, example: "{{applicant.email}}" }
            action_taken:
              type: string
              enum: [denied, revoked, adverse_employment, other_action]
              description: "The action box ticked on the notice: `denied` (application denied), `revoked` (credit revoked or terms changed unfavorably), `adverse_employment` (adverse employment action) or `other_action`. Cascades the AI designer generates always use `denied`."
            send_strategy: { type: string, enum: [draft_first, auto_send] }
            reason: { type: string }
          additionalProperties: true
        internal_notification:
          type: object
          description: "`Action` with `action_type: InternalNotification`. Operator-facing notification settings."
          additionalProperties: true
        wait:
          type: object
          description: "`Wait` only. The delay before the next node runs."
          properties:
            duration: { type: number, example: 24 }
            unit: { type: string, enum: [seconds, minutes, hours, days, weeks], example: "hours" }
        target_step:
          type: string
          description: "`GoTo` only. The node id to jump to."
      additionalProperties: true
      x-required-keys-by-type:
        ApplicantSource: []
        BillingProduct: [billing_product_code]
        Product: [billing_product_code]
        IfElse: [branches]
        Condition: [branches]
        Wait: [wait]
        GoTo: [target_step]
        ShowResults: []
        Action: [action_type]

    CascadeBranch:
      type: object
      description: A branch of an `IfElse` node — one or more condition groups combined with `group_logic`.
      properties:
        group_logic:
          type: string
          enum: [and, or]
          description: How the condition groups combine. Defaults to `and`.
        condition_groups:
          type: array
          items:
            type: object
            required: [conditions]
            properties:
              logic:
                type: string
                enum: [and, or]
                description: How the conditions inside this group combine. Defaults to `and`.
              conditions:
                type: array
                items: { $ref: "#/components/schemas/CascadeCondition" }

    CascadeCondition:
      type: object
      description: A single comparison against a product node's output (or a built-in context variable such as `applicant.email`).
      required: [source_node_id, field, operator]
      properties:
        source_node_id:
          type: string
          description: The `id` of the product node whose output is compared.
          example: "step_credit"
        field:
          type: string
          description: Exact output field path from that product's schema, e.g. `creditScore.fico9`.
          example: "creditScore.fico9"
        operator:
          type: string
          enum:
            - equals
            - not_equals
            - greater_than
            - greater_than_or_equal
            - less_than
            - less_than_or_equal
            - contains
            - not_contains
            - starts_with
            - ends_with
            - is_empty
            - is_not_empty
            - is_true
            - is_false
        value:
          description: The comparison value. Required for every operator except the unary ones (`is_empty`, `is_not_empty`, `is_true`, `is_false`), which accept `null` or no `value` at all.
          oneOf:
            - type: string
              nullable: true
            - type: number
            - type: boolean
          example: 650

    # --- Workflows ------------------------------------------------------------

    Workflow:
      type: object
      properties:
        id: { type: integer, example: 27 }
        object: { type: string, example: "workflow" }
        code: { type: string, nullable: true, example: "TSS-27", description: Short human-readable code assigned by Cascades. }
        name: { type: string, example: "Tenant Screening Standard" }
        active:
          type: boolean
          description: "`true` = activated and triggerable in live mode. Drafts (`false`) can still be triggered in test mode."
        trigger_type: { type: string, enum: [manual, event, scheduled], example: "manual" }
        use_case: { type: string, example: "tenant_screening", nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, object, name, active, trigger_type]

    WorkflowDetail:
      allOf:
        - $ref: "#/components/schemas/Workflow"
        - type: object
          properties:
            definition: { $ref: "#/components/schemas/CascadeDefinition" }

    WorkflowCreateRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, example: "Tenant Screening Standard" }
        definition:
          allOf:
            - $ref: "#/components/schemas/CascadeDefinition"
          description: The node graph. Omit or pass `[]` to create an empty draft you fill in later.
        use_case: { type: string, example: "tenant_screening", description: Optional use case label. }
        trigger_type:
          type: string
          enum: [manual, event, scheduled]
          default: manual
          description: API-created cascades default to `manual` so they can be triggered through this API. Unknown values fall back to `manual`.
        active:
          type: boolean
          default: true
          description: |
            Whether the cascade is immediately triggerable in live mode.
            Omitted or `null` means `true`; pass `false` for a draft. Accounts
            without production access always receive a draft regardless of
            this value, and the response carries a `note` explaining why.

    WorkflowUpdateRequest:
      type: object
      description: PATCH semantics — only the keys present in the body change; omitted keys keep their current value.
      properties:
        name: { type: string, minLength: 1 }
        definition:
          allOf:
            - $ref: "#/components/schemas/CascadeDefinition"
          description: Replacement node graph (the full array, not a diff). Read the current one with `getWorkflow` first.
        use_case: { type: string, nullable: true }
        trigger_type:
          type: string
          enum: [manual, event, scheduled]
          description: Unknown values are ignored (the current value is kept).
        active:
          type: boolean
          description: "`true` activates, `false` makes it a draft. Activating requires production access (`403 activation_forbidden` otherwise)."

    WorkflowGenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          minLength: 1
          maxLength: 5000
          description: Plain-English description of the decision policy to turn into a cascade (max 5 000 characters by default).
          example: "Pull an Equifax credit report. Approve if FICO is at least 650; send an adverse-action notice otherwise."
        name: { type: string, description: Optional name override; otherwise the generator names the cascade. }
        use_case: { type: string, description: Optional use case label. Defaults to `tenant_screening`. }
        create:
          type: boolean
          default: true
          description: "`true` (default) saves the generated cascade and returns `201`; `false` returns the generated definition as a `200` preview without saving."

    CascadeGeneration:
      type: object
      description: Metadata about the AI generation that produced a definition.
      properties:
        name: { type: string, description: The name the generator chose. }
        model: { type: string, example: "claude-opus-4-8", description: The model that produced the definition. }
        repaired: { type: boolean, description: "`true` when the first draft failed validation and a repair pass fixed it." }
        warnings:
          type: array
          description: Non-blocking findings from the definition validator (unreachable nodes, compliance hints).
          items: { $ref: "#/components/schemas/CascadeGenerationWarning" }

    CascadeGenerationWarning:
      type: object
      description: One non-blocking finding from the definition validator, addressed to the node it concerns. The cascade is still valid and runnable.
      required: [node_id, code, message]
      properties:
        node_id: { type: string, example: "step_fico_check", description: "The `id` of the node the finding is about." }
        code:
          type: string
          example: missing_adverse_action
          description: "Machine-readable finding. Current codes: `unreachable_node`, `missing_adverse_action`, `generic_adverse_action_reason`, `folded_thresholds`, `jurisdiction_review`."
        message:
          type: string
          example: "Decision node \"step_fico_check\" has a fail branch that does not end in an AdverseAction. FCRA declines should send an adverse-action notice."
          description: Human-readable explanation with the suggested fix.

    CascadeGenerationPreview:
      type: object
      description: Returned by `generateWorkflow` when `create` is `false`. Nothing is saved.
      properties:
        object: { type: string, enum: [cascade_generation] }
        definition: { $ref: "#/components/schemas/CascadeDefinition" }
        valid: { type: boolean, description: Whether the definition passed the validator. }
        name: { type: string }
        model: { type: string }
        repaired: { type: boolean }
        warnings:
          type: array
          items: { $ref: "#/components/schemas/CascadeGenerationWarning" }

    SandboxDraftNote:
      type: object
      properties:
        note:
          type: string
          description: Present only when the account lacks production access and the cascade was therefore created as a draft.
          example: "Created as a draft: activating a cascade requires production access. Drafts run in test mode; live runs need an activated account."

    TestCase:
      type: object
      description: A registered sandbox subject for one of the cascade's products, with a payload ready to send to `triggerWorkflow`.
      properties:
        id: { type: integer, example: 54, description: The subject id in the CRS Testcase registry. }
        case_name: { type: string, example: "Cascades_Credit_660_Inq0" }
        product_code: { type: string, example: "TUC1001", description: The billing product code this subject was registered for. }
        payload:
          type: object
          description: Drop-in body for `triggerWorkflow` (`payload.applicant` and, when the subject carries one, `payload.property`).
          properties:
            applicant: { $ref: "#/components/schemas/ApplicantPayload" }
            property:
              type: object
              additionalProperties: true
              description: Property / tenancy details when the subject was registered with them.

    TestCaseList:
      type: object
      properties:
        object: { type: string, enum: [test_case_list] }
        workflow_id: { type: integer, example: 27 }
        test_cases:
          type: array
          items: { $ref: "#/components/schemas/TestCase" }

    WorkflowTemplate:
      type: object
      properties:
        id: { type: integer }
        object: { type: string, example: "workflow_template" }
        name: { type: string }
        description: { type: string, nullable: true }
        icon: { type: string, nullable: true }
        color: { type: string, nullable: true }
        category: { type: string, nullable: true }
        active: { type: boolean }
        featured: { type: boolean }
        published_at: { type: string, format: date-time, nullable: true }

    WorkflowTemplateDetail:
      allOf:
        - $ref: "#/components/schemas/WorkflowTemplate"
        - type: object
          properties:
            definition: { $ref: "#/components/schemas/CascadeDefinition" }
            source_workflow_id: { type: integer, nullable: true }

    # --- Executions -------------------------------------------------------------

    ExecutionStatus:
      type: string
      description: |
        Lifecycle of an execution. `queued` (batch rows not yet dispatched),
        `pending`, `running` and `waiting` (paused on a Wait node or
        adverse-action waiting period) are in flight; `awaiting_review` is
        paused on human review; `completed`, `failed`, `cancelled` and
        `skipped` are terminal.
      enum: [queued, pending, running, waiting, awaiting_review, completed, failed, cancelled, skipped]

    ReviewStatus:
      type: string
      enum: [none, in_review, approved, denied]

    ExecutionSummary:
      type: object
      description: |
        The shape returned by `triggerWorkflow`. Compared with `Execution`
        it carries `execution_mode` and never `assigned_to_user_id`.
      properties:
        id: { type: integer, example: 1842 }
        object: { type: string, example: "execution" }
        workflow_id: { type: integer, example: 27 }
        status: { $ref: "#/components/schemas/ExecutionStatus" }
        execution_mode: { type: string, enum: [test, live] }
        execution_key:
          type: string
          nullable: true
          description: The namespaced idempotency key (`api_client:<id>:<Idempotency-Key>`).
          example: "api_client:12:3fa85f64-5717-4562-b3fc-2c963f66afa6"
        external_reference: { type: string, nullable: true, example: "rv-app-20491" }
        needs_human_review: { type: boolean }
        review_status: { $ref: "#/components/schemas/ReviewStatus" }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }
      required: [id, object, workflow_id, status, needs_human_review, review_status, created_at]

    Execution:
      type: object
      description: The list/detail shape returned by the `/executions` endpoints.
      properties:
        id: { type: integer, example: 1842 }
        object: { type: string, example: "execution" }
        workflow_id: { type: integer, example: 27 }
        status: { $ref: "#/components/schemas/ExecutionStatus" }
        execution_key: { type: string, nullable: true, example: "api_client:12:3fa85f64-5717-4562-b3fc-2c963f66afa6" }
        external_reference: { type: string, nullable: true, example: "rv-app-20491" }
        needs_human_review: { type: boolean }
        review_status: { $ref: "#/components/schemas/ReviewStatus" }
        assigned_to_user_id: { type: integer, nullable: true, description: "The reviewer who claimed this execution, if any." }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }
      required: [id, object, workflow_id, status, needs_human_review, review_status, created_at]

    ExecutionDetail:
      allOf:
        - $ref: "#/components/schemas/Execution"
        - type: object
          properties:
            input_context:
              type: object
              additionalProperties: true
              description: |
                The payload originally sent at trigger time, plus resolved
                fields such as `execution_mode`, `submitted_by_user_id`,
                `user_approved`, `external_reference`, (for a test-mode
                run of a draft) `allow_inactive` and (for a test-mode run
                that replays a registered test subject) `sandbox_replay`.

    ExecutionFull:
      allOf:
        - $ref: "#/components/schemas/ExecutionDetail"
        - type: object
          description: Adds raw step output and failure detail. Gated behind
            `executions:read:full` because step_outputs typically contains
            applicant SSN, DOB, addresses, and vendor responses.
          properties:
            step_outputs:
              type: object
              description: Per-node results keyed by node id.
              additionalProperties: { type: object }
            failed_steps:
              type: array
              items: { type: object }

    ReviewEvent:
      type: object
      properties:
        id: { type: integer }
        object: { type: string, example: "review_event" }
        execution_id: { type: integer }
        event_type:
          type: string
          enum:
            - flagged
            - assigned
            - unassigned
            - approved
            - denied
            - note_added
            - re_evaluated
            - adverse_action_drafted
            - adverse_action_sent
            - adverse_action_skipped
            - adverse_action_replayed
          description: |
            The review events plus the adverse-action notice trail:
            `adverse_action_drafted` (a notice draft was parked for review),
            `adverse_action_sent` (the draft was sent), `adverse_action_skipped`
            (the draft was discarded without sending) and
            `adverse_action_replayed` (a sent notice was resent from the app).
        actor_user_id: { type: integer, nullable: true }
        note: { type: string, nullable: true }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }

    Artifact:
      type: object
      description: Metadata for a downloadable PDF attached to an execution.
      properties:
        id: { type: integer, description: Artifact id; pass it as `artifact_id` to the matching download endpoint. }
        object: { type: string, enum: [report_artifact, notice_artifact] }
        execution_id: { type: integer }
        filename: { type: string, example: "Adverse_Action_Notice.pdf" }
        content_type: { type: string, example: "application/pdf" }
        byte_size: { type: integer }
        generated_at: { type: string, format: date-time, nullable: true }
        downloadable:
          type: boolean
          description: "`false` when the record exists but its file is no longer stored. Downloading it then returns `404 not_found`."

    ApplicantPayload:
      type: object
      description: Free-form applicant data forwarded to the workflow's
        first node as `context.applicant`. Field names depend on the
        workflow definition.
      additionalProperties: true
      example:
        first_name: "Jordan"
        last_name: "Lee"
        email: "jordan@example.com"
        ssn: "123-45-6789"
        dob: "1989-04-12"

    TriggerRequest:
      type: object
      description: All fields are optional; an empty body starts a test-mode run with an empty payload.
      properties:
        payload:
          type: object
          description: The decision input. Put the subject under `applicant`; arbitrary nested fields are allowed and become the execution's `input_context`. Defaults to `{}` when omitted.
          properties:
            applicant: { $ref: "#/components/schemas/ApplicantPayload" }
            property:
              type: object
              additionalProperties: true
              description: Optional property / tenancy details (as returned by `listWorkflowTestCases`).
          additionalProperties: true
        execution_mode:
          type: string
          enum: [test, live]
          default: test
          description: "`test` uses synthetic vendor data and never bills; `live` requires production access and an active cascade."
        external_reference:
          type: string
          maxLength: 200
          example: "rv-app-20491"
          description: Your correlation id for this run. Echoed on the execution and on every webhook event.

    TriggerResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/ExecutionSummary" }
        meta: { $ref: "#/components/schemas/Meta" }

    ReviewDecisionRequest:
      type: object
      properties:
        decision:
          type: string
          enum: [approved, denied]
        note: { type: string }
      required: [decision]

    ReviewAssignmentRequest:
      type: object
      properties:
        assignee:
          type: string
          nullable: true
          enum: ["me", null]
          description: "`me` claims for the calling api_user; null unassigns."
        note: { type: string }

    ReviewNoteRequest:
      type: object
      properties:
        note: { type: string, minLength: 1 }
      required: [note]

    BulkReviewRequest:
      type: object
      properties:
        execution_ids:
          type: array
          items: { type: integer }
          minItems: 1
          maxItems: 25
        bulk_action:
          type: string
          enum: [approve, deny, assign_to_me]
        note: { type: string }
      required: [execution_ids, bulk_action]

    BulkReviewResult:
      type: object
      properties:
        object: { type: string, example: "bulk_review_result" }
        applied: { type: integer }
        enqueued: { type: integer }
        skipped:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              reason:
                type: string
                enum:
                  - not_awaiting_review
                  - review_not_pending
                  - assigned_elsewhere
                  - live_mode_forbidden
                  - already_assigned
                  - not_found

    # --- Batches -----------------------------------------------------------------

    Batch:
      type: object
      properties:
        id: { type: integer, example: 77 }
        object: { type: string, example: "batch" }
        workflow_id: { type: integer }
        status:
          type: string
          enum: [pending, running, interrupted, completed, cancelled, failed]
          description: |
            `completed` means every row reached a terminal status, even when
            some rows failed (see `failed_count`). `failed` means the batch
            itself stopped on an unexpected error; it is not accepted by
            `filter[status]` on `listBatches`.
        execution_mode: { type: string, enum: [test, live] }
        external_reference: { type: string, nullable: true, example: "rentvine-batch-001" }
        total_count: { type: integer }
        completed_count: { type: integer }
        failed_count: { type: integer }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    BatchExecutionRow:
      type: object
      description: |
        Subset of Execution fields surfaced when listing the children of a
        batch. Tokens with `executions:read:full` additionally receive
        `applicant_name` and `applicant_email`.
      properties:
        id: { type: integer }
        object: { type: string, example: "execution" }
        batch_execution_id: { type: integer }
        workflow_id: { type: integer }
        row_index: { type: integer, description: 0-based position in the submitted `rows` array. }
        status: { $ref: "#/components/schemas/ExecutionStatus" }
        retry_count: { type: integer }
        external_reference: { type: string, nullable: true }
        needs_human_review: { type: boolean }
        review_status: { $ref: "#/components/schemas/ReviewStatus" }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        applicant_name: { type: string, nullable: true, description: Only with `executions:read:full`. }
        applicant_email: { type: string, nullable: true, description: Only with `executions:read:full`. }

    BatchCreateRequest:
      type: object
      properties:
        rows:
          type: array
          minItems: 1
          maxItems: 500
          description: One object per applicant. Every row must be an object.
          items:
            type: object
            properties:
              external_reference: { type: string, maxLength: 200 }
              applicant: { $ref: "#/components/schemas/ApplicantPayload" }
        execution_mode: { type: string, enum: [test, live], default: test }
        external_reference: { type: string, maxLength: 200 }
      required: [rows]

    # --- Webhooks ------------------------------------------------------------------

    WebhookEventName:
      type: string
      description: Lifecycle events Cascades can deliver. See `x-webhooks` for each payload.
      enum:
        - execution.completed
        - execution.failed
        - adverse_action.triggered
        - adverse_action.completed
        - adverse_action.failed
        - adverse_action.retry_scheduled

    WebhookEnvelope:
      type: object
      description: |
        The body of every webhook delivery. `data` is event-specific — see
        `x-webhooks`. Nil-valued fields are omitted from `data` rather than
        sent as `null`.
      required: [id, event, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: Event id. Identical across every subscriber's copy of the same event and across retries; use it to de-duplicate.
          example: "6d1f1a5e-4a2b-4c7d-9e8f-0a1b2c3d4e5f"
        event: { $ref: "#/components/schemas/WebhookEventName" }
        created_at: { type: string, format: date-time, description: When the event was emitted (not when this attempt was sent). }
        data:
          type: object
          additionalProperties: true

    WebhookSubscription:
      type: object
      properties:
        id: { type: integer }
        object: { type: string, example: "webhook_subscription" }
        url: { type: string, example: "https://hooks.partner.example/cascades" }
        event_filter:
          type: array
          description: Empty = subscribed to ALL events.
          items: { $ref: "#/components/schemas/WebhookEventName" }
        active: { type: boolean }
        last_delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookSubscriptionWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookSubscription"
        - type: object
          description: Returned ONLY by POST and rotate_secret. Subsequent
            reads (show / list / update) never re-expose the secret.
          properties:
            signing_secret:
              type: string
              example: "whsec_4f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"

    WebhookSubscriptionCreateRequest:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: HTTPS endpoint. Must resolve to a public address (no private, loopback or cloud-metadata ranges).
        event_filter:
          type: array
          description: Events to receive. Omit or pass `[]` for all events; unknown names return `422`.
          items: { $ref: "#/components/schemas/WebhookEventName" }
        active: { type: boolean, default: true }
      required: [url]

    WebhookSubscriptionUpdateRequest:
      type: object
      description: PATCH semantics — only the keys present change.
      properties:
        url: { type: string, format: uri }
        event_filter:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventName" }
        active: { type: boolean }

    WebhookDelivery:
      type: object
      properties:
        id: { type: integer }
        object: { type: string, example: "webhook_delivery" }
        webhook_subscription_id: { type: integer }
        event_type: { $ref: "#/components/schemas/WebhookEventName" }
        status:
          type: string
          enum: [pending, retrying, succeeded, failed, dead]
        attempts: { type: integer }
        max_attempts: { type: integer, example: 6 }
        last_response_status: { type: integer, nullable: true }
        last_attempted_at: { type: string, format: date-time, nullable: true }
        next_retry_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

  responses:
    Unauthorized:
      description: Missing, malformed, expired or revoked bearer token. The `WWW-Authenticate` header carries the RFC 6750 challenge.
      headers:
        WWW-Authenticate:
          description: "`Bearer realm=\"api\"` when no token was sent; `Bearer error=\"invalid_token\"` when a bad one was."
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: unauthorized
              message: A valid bearer token is required.
              details: []
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/unauthorized
    InsufficientScope:
      description: Token does not hold the required scope. `details` lists the scope(s) that were checked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: insufficient_scope
              message: This token does not have the required scope.
              details:
                - { field: scope, message: "executions:write" }
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/insufficient_scope
    NotFound:
      description: Resource does not exist or belongs to another tenant / api_client
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: not_found
              message: The requested resource was not found.
              details: []
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/not_found
    ValidationFailed:
      description: Request body or params failed validation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: validation_failed
              message: The request could not be processed.
              details:
                - { field: execution_mode, message: "must be test or live" }
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
    Conflict:
      description: Resource is in a state that conflicts with the requested operation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: workflow_inactive
              message: Draft workflows run in test mode only. Activate the workflow before triggering it live.
              details: []
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/workflow_inactive
    RateLimited:
      description: |
        Rate limit exceeded (60 req/min per IP on `/api/v1`, 600 req/hour
        per token prefix, or 300 req/5 min per IP globally). The body is the
        standard error envelope and `Retry-After` gives the seconds until
        the window resets.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, minimum: 1 }
          example: 37
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded. Retry later.
              details: []
              request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
              doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/rate_limited

security:
  - bearerAuth: []

tags:
  - name: workflows
    description: |
      Cascades — the decision workflows you author (by hand, from a
      template, or with the AI generator) and run against applicants.
      Workflows are owned by the account behind the token; drafts run in
      test mode, activated cascades can also run live.
  - name: workflow_templates
    description: Read-only catalog of published cascade templates you can copy a `definition` from.
  - name: executions
    description: |
      Individual runs of a cascade: polling status and results, cancelling,
      re-evaluating, human-review decisions and audit trail, and the PDF
      artifacts (credit reports, adverse-action notices) a run produced.
  - name: batches
    description: Bulk runs of up to 500 applicants against one cascade, with per-row status, cancel / resume / retry, and CSV / ZIP exports.
  - name: webhooks
    description: Subscriptions to lifecycle events, signing-secret rotation, and the delivery log with manual retry. The events themselves are documented under `x-webhooks`.

# --------------------------------------------------------------------------
# Webhook events (shaped like the OpenAPI 3.1 `webhooks` object; kept under
# an extension key because this document is 3.0.3). Each entry describes
# the POST Cascades makes TO your subscriber URL. Payload schemas are inlined
# (no $ref) so tooling that reads this block raw does not need a resolver.
# Field sources: WorkflowExecution#emit_terminal_event and
# Workflows::Executors::SendAdverseActionExecutor#emit_adverse_action_event.
# --------------------------------------------------------------------------
x-webhooks:
  execution.completed:
    post:
      operationId: webhookExecutionCompleted
      summary: An execution reached `completed`
      description: |
        Sent once when an execution's status transitions to `completed`.
        Emitted for API-triggered runs only (executions started from the
        Cascades UI have no api_client and produce no webhooks). Poll
        `GET /executions/{workflow_execution_id}` for the decision and
        `step_outputs`.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [execution.completed] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          description: The `WebhookDelivery` id.
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          description: "`t=<unix>,v1=<hmac>[,v0=<hmac>]` — HMAC-SHA256 over `\"<t>.<body>\"`."
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [execution.completed] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, status, review_status]
                  properties:
                    workflow_execution_id: { type: integer, example: 1842 }
                    workflow_id: { type: integer, example: 27 }
                    batch_execution_id: { type: integer, description: Present only for batch rows. }
                    external_reference: { type: string, description: Present when the run was triggered with one. }
                    execution_mode: { type: string, enum: [test, live] }
                    status: { type: string, enum: [completed] }
                    review_status: { type: string, enum: [none, in_review, approved, denied] }
                    completed_at: { type: string, format: date-time }
            example:
              id: "6d1f1a5e-4a2b-4c7d-9e8f-0a1b2c3d4e5f"
              event: "execution.completed"
              created_at: "2026-09-09T14:03:21Z"
              data:
                workflow_execution_id: 1842
                workflow_id: 27
                external_reference: "rv-app-20491"
                execution_mode: "test"
                status: "completed"
                review_status: "none"
                completed_at: "2026-09-09T14:03:20Z"
      responses:
        "2XX":
          description: Acknowledged. Any 2xx marks the delivery `succeeded`; anything else is retried on the documented schedule.

  execution.failed:
    post:
      operationId: webhookExecutionFailed
      summary: An execution reached `failed`
      description: |
        Sent once when an execution's status transitions to `failed`.
        `first_error_message` is the engine's first recorded error; the
        full `failed_steps` array is available from
        `GET /executions/{workflow_execution_id}` with `executions:read:full`.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [execution.failed] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [execution.failed] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, status, review_status]
                  properties:
                    workflow_execution_id: { type: integer }
                    workflow_id: { type: integer }
                    batch_execution_id: { type: integer, description: Present only for batch rows. }
                    external_reference: { type: string }
                    execution_mode: { type: string, enum: [test, live] }
                    status: { type: string, enum: [failed] }
                    review_status: { type: string, enum: [none, in_review, approved, denied] }
                    completed_at: { type: string, format: date-time }
                    first_error_message: { type: string, description: The first error the engine recorded for this run. }
            example:
              id: "9b2e7c10-3d4f-4a5b-8c6d-7e8f9a0b1c2d"
              event: "execution.failed"
              created_at: "2026-09-09T14:05:02Z"
              data:
                workflow_execution_id: 1843
                workflow_id: 27
                external_reference: "rv-app-20492"
                execution_mode: "live"
                status: "failed"
                review_status: "none"
                completed_at: "2026-09-09T14:05:01Z"
                first_error_message: "Equifax: subject not found"
      responses:
        "2XX":
          description: Acknowledged.

  adverse_action.triggered:
    post:
      operationId: webhookAdverseActionTriggered
      summary: An adverse-action notice was sent (or the pre-adverse waiting period started)
      description: |
        Emitted by the `AdverseAction` node when a notice goes out. For the
        two-step employment flow (`notice_stage: pre_adverse`) the execution
        then enters `waiting` until `resume_at`, when the post-adverse notice
        is sent. For single-notice flows (`notice_stage: final`) it is
        immediately followed by `adverse_action.completed`.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [adverse_action.triggered] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [adverse_action.triggered] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, node_id, flow, notice_stage, status]
                  properties:
                    workflow_execution_id: { type: integer }
                    workflow_id: { type: integer }
                    node_id: { type: string, description: The `AdverseAction` node's id in the definition. }
                    external_reference: { type: string }
                    flow:
                      type: string
                      description: "`employment` for the two-step pre/post flow; otherwise the workflow's use case (e.g. `tenant_screening`)."
                      example: "tenant_screening"
                    notice_stage: { type: string, enum: [pre_adverse, final] }
                    sent_to: { type: string, description: Recipient address the notice was sent to. }
                    delivery_channels:
                      type: array
                      description: "`final` stage only: the channels used. Currently always `digital` (physical mail is not yet enabled)."
                      items: { type: string }
                    triggered_at: { type: string, format: date-time }
                    resume_at: { type: string, format: date-time, description: "`pre_adverse` only: when the post-adverse notice will be sent." }
                    status: { type: string, enum: [waiting, completed] }
            example:
              id: "1c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
              event: "adverse_action.triggered"
              created_at: "2026-09-09T14:06:40Z"
              data:
                workflow_execution_id: 1844
                workflow_id: 27
                node_id: "step_adverse_action"
                external_reference: "rv-app-20493"
                flow: "tenant_screening"
                notice_stage: "final"
                sent_to: "jordan@example.com"
                delivery_channels: ["digital"]
                triggered_at: "2026-09-09T14:06:39Z"
                status: "completed"
      responses:
        "2XX":
          description: Acknowledged.

  adverse_action.completed:
    post:
      operationId: webhookAdverseActionCompleted
      summary: The adverse-action flow finished
      description: |
        Emitted when the final (or post-adverse) notice has been delivered.
        The notice PDF is listed by `GET /executions/{workflow_execution_id}/notice_artifacts`.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [adverse_action.completed] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [adverse_action.completed] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, node_id, flow, notice_stage, status]
                  properties:
                    workflow_execution_id: { type: integer }
                    workflow_id: { type: integer }
                    node_id: { type: string }
                    external_reference: { type: string }
                    flow: { type: string, example: "tenant_screening" }
                    notice_stage: { type: string, enum: [post_adverse, final] }
                    sent_to: { type: string }
                    delivery_channels:
                      type: array
                      items: { type: string }
                    completed_at: { type: string, format: date-time }
                    status: { type: string, enum: [completed] }
            example:
              id: "2d0e9f8a-7b6c-4d5e-3f2a-1b0c9d8e7f6a"
              event: "adverse_action.completed"
              created_at: "2026-09-09T14:06:41Z"
              data:
                workflow_execution_id: 1844
                workflow_id: 27
                node_id: "step_adverse_action"
                external_reference: "rv-app-20493"
                flow: "tenant_screening"
                notice_stage: "final"
                sent_to: "jordan@example.com"
                delivery_channels: ["digital"]
                completed_at: "2026-09-09T14:06:39Z"
                status: "completed"
      responses:
        "2XX":
          description: Acknowledged.

  adverse_action.failed:
    post:
      operationId: webhookAdverseActionFailed
      summary: A notice could not be delivered
      description: |
        Emitted when sending a notice raised an error. For the post-adverse
        stage this fires only after the retry budget is exhausted (see
        `adverse_action.retry_scheduled` for the intermediate attempts); the
        execution then fails.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [adverse_action.failed] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [adverse_action.failed] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, node_id, flow, notice_stage, error, status]
                  properties:
                    workflow_execution_id: { type: integer }
                    workflow_id: { type: integer }
                    node_id: { type: string }
                    external_reference: { type: string }
                    flow: { type: string }
                    notice_stage: { type: string, enum: [pre_adverse, post_adverse, final] }
                    error: { type: string, description: Human-readable delivery error. }
                    failed_at: { type: string, format: date-time }
                    attempt: { type: integer, description: "`post_adverse` only: the attempt number that exhausted the retry budget." }
                    status: { type: string, enum: [failed] }
            example:
              id: "3e1f0a9b-8c7d-4e6f-5a4b-2c1d0e9f8a7b"
              event: "adverse_action.failed"
              created_at: "2026-09-09T14:08:12Z"
              data:
                workflow_execution_id: 1845
                workflow_id: 27
                node_id: "step_adverse_action"
                flow: "tenant_screening"
                notice_stage: "final"
                error: "Failed to send adverse action notice to jordan@example.com: SMTP 550 mailbox unavailable"
                failed_at: "2026-09-09T14:08:11Z"
                status: "failed"
      responses:
        "2XX":
          description: Acknowledged.

  adverse_action.retry_scheduled:
    post:
      operationId: webhookAdverseActionRetryScheduled
      summary: A post-adverse notice delivery failed and a retry was scheduled
      description: |
        Employment (two-step) flow only. The post-adverse notice could not
        be delivered; Cascades scheduled another attempt at `retry_at` with
        exponential backoff. `adverse_action.failed` follows if the retry
        budget runs out.
      parameters:
        - name: X-Cascades-Event
          in: header
          required: true
          schema: { type: string, enum: [adverse_action.retry_scheduled] }
        - name: X-Cascades-Delivery
          in: header
          required: true
          schema: { type: integer }
        - name: X-Cascades-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, created_at, data]
              properties:
                id: { type: string, format: uuid }
                event: { type: string, enum: [adverse_action.retry_scheduled] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  required: [workflow_execution_id, workflow_id, node_id, flow, notice_stage, error, attempt, next_attempt, retry_at]
                  properties:
                    workflow_execution_id: { type: integer }
                    workflow_id: { type: integer }
                    node_id: { type: string }
                    external_reference: { type: string }
                    flow: { type: string, enum: [employment] }
                    notice_stage: { type: string, enum: [post_adverse] }
                    error: { type: string }
                    attempt: { type: integer, description: The attempt that just failed (1-based). }
                    next_attempt: { type: integer }
                    retry_at: { type: string, format: date-time }
                    wait_id: { type: integer, description: Id of the internal wait record backing the retry. }
            example:
              id: "4f2a1b0c-9d8e-4f7a-6b5c-3d2e1f0a9b8c"
              event: "adverse_action.retry_scheduled"
              created_at: "2026-09-09T14:09:00Z"
              data:
                workflow_execution_id: 1846
                workflow_id: 31
                node_id: "step_adverse_action"
                flow: "employment"
                notice_stage: "post_adverse"
                error: "SMTP connection timed out"
                attempt: 1
                next_attempt: 2
                retry_at: "2026-09-09T14:39:00Z"
                wait_id: 9021
      responses:
        "2XX":
          description: Acknowledged.

paths:
  # ==========================================================================
  # Workflows
  # ==========================================================================
  /workflows:
    get:
      operationId: listWorkflows
      tags: [workflows]
      summary: List workflows
      description: |
        Workflows owned by the account behind the token, newest first.
        Hidden single-report runner workflows are excluded. Requires
        `workflows:read`.
      x-scopes: [workflows:read]
      x-doc-page: /docs/concepts/cascades
      x-mcp:
        tool: cascades_list_workflows
        description: >-
          List the caller's saved Cascades (decision workflows) — their id, code,
          name, use_case, trigger_type, and whether each is active. Use this first
          when the user refers to a cascade by name or asks "what can I run", to
          discover the workflow id you then pass to cascades_trigger_workflow. Only
          workflows owned by the API key's account are returned. Optional filters
          narrow the list.
        annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[active]",       in: query, description: "Only active (`true`) or draft (`false`) workflows.", schema: { type: boolean } }
        - { name: "filter[use_case]",     in: query, description: "Exact match, e.g. `tenant_screening`.", schema: { type: string } }
        - { name: "filter[trigger_type]", in: query, description: "Exact match. Not validated: a value outside the list returns an empty list, not an error.", schema: { type: string, enum: [manual, event, scheduled] } }
      responses:
        "200":
          description: Workflow list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Workflow" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
              example:
                data:
                  - id: 27
                    object: workflow
                    code: TSS-27
                    name: Tenant Screening Standard
                    active: true
                    trigger_type: manual
                    use_case: tenant_screening
                    created_at: "2026-08-01T10:00:00Z"
                    updated_at: "2026-09-01T09:30:00Z"
                meta: { page: 1, per_page: 25, total_count: 1, total_pages: 1, object: workflow, request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createWorkflow
      tags: [workflows]
      summary: Create a workflow
      description: |
        Creates a cascade from a JSON `definition` (see `CascadeDefinition`
        for the node grammar), owned by the account behind the token.
        API-created cascades default to `trigger_type: manual` so they can
        be run through `triggerWorkflow`.

        `active` defaults to `true`. Accounts **without production access**
        always get a draft (`active: false`) — silently, with a `note` in
        the response explaining why. Drafts can still be triggered in test
        mode; only live runs need an activated cascade and an approved
        account. Requires `workflows:write`.
      x-scopes: [workflows:write]
      x-doc-page: /docs/concepts/cascades
      x-mcp:
        tool: cascades_create_workflow
        description: >-
          Create a new cascade (decision workflow) from a JSON definition, owned by
          the API key's account. Use this to turn a described policy into a runnable
          cascade: compose the node graph in `definition` and POST it. The cascade is
          active by default, so you can immediately run it with
          cascades_trigger_workflow using the returned id — pass active:false to
          create a draft instead. Sandbox accounts always receive a draft, which
          still runs in test mode.
        annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
      requestBody:
        description: "The cascade to create: its name, use case and node definition."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowCreateRequest" }
            example:
              name: "Tenant Screening Standard"
              use_case: "tenant_screening"
              trigger_type: "manual"
              definition:
                - { id: "step_applicant", type: "ApplicantSource", name: "Applicant", next_step: ["step_credit"] }
                - { id: "step_credit", type: "BillingProduct", name: "Credit report", billing_product_code: "EQF1003", next_step: ["step_score_check"] }
                - id: "step_score_check"
                  type: "IfElse"
                  name: "Score at least 650?"
                  branches:
                    pass:
                      condition_groups:
                        - conditions:
                            - { source_node_id: "step_credit", field: "creditScore.fico9", operator: "greater_than_or_equal", value: 650 }
                  pass_steps: ["step_approve"]
                  fail_steps: ["step_adverse_action"]
                - { id: "step_approve", type: "Action", name: "Approve", action_type: "Approve" }
                - id: "step_adverse_action"
                  type: "Action"
                  name: "Adverse action"
                  action_type: "AdverseAction"
                  adverse_action: { recipient: "{{applicant.email}}", action_taken: "denied", send_strategy: "draft_first", reason: "Credit score below the property minimum." }
      responses:
        "201":
          description: Created. `note` is present only when the account lacks production access and the cascade was saved as a draft.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Workflow"
                      - $ref: "#/components/schemas/SandboxDraftNote"
                  meta: { $ref: "#/components/schemas/Meta" }
              examples:
                approved_account:
                  summary: Account with production access
                  value:
                    data: { id: 41, object: workflow, code: TSS-41, name: Tenant Screening Standard, active: true, trigger_type: manual, use_case: tenant_screening, created_at: "2026-09-09T14:00:00Z", updated_at: "2026-09-09T14:00:00Z" }
                    meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
                sandbox_account:
                  summary: Sandbox account (saved as a draft)
                  value:
                    data:
                      id: 41
                      object: workflow
                      code: TSS-41
                      name: Tenant Screening Standard
                      active: false
                      trigger_type: manual
                      use_case: tenant_screening
                      created_at: "2026-09-09T14:00:00Z"
                      updated_at: "2026-09-09T14:00:00Z"
                      note: "Created as a draft: activating a cascade requires production access. Drafts run in test mode; live runs need an activated account."
                    meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "400":
          description: "`malformed_body` (unparseable JSON) or `bad_request`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: malformed_body
                  message: The request body could not be parsed. Send valid JSON matching the Content-Type header.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/malformed_body
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: "`validation_failed` — e.g. blank `name`, a `definition` that is not an array of objects, or more than one `ApplicantSource` node."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The workflow could not be created.
                  details:
                    - { field: name, message: "can't be blank" }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /workflows/generate:
    post:
      operationId: generateWorkflow
      tags: [workflows]
      summary: Generate a workflow from a prompt
      description: |
        Builds a cascade definition from a plain-English policy description
        using the AI builder, validates it (with one automatic repair pass),
        and — by default — saves it as a new workflow exactly like
        `createWorkflow` (same draft rule for sandbox accounts, same `note`).
        Pass `create: false` to receive the generated definition as a
        preview without saving anything.

        Generation shares a small concurrency budget with the in-app
        designer; when every slot is busy the call returns
        `503 designer_busy` and should be retried after a short delay.
        Requires `workflows:write`.
      x-scopes: [workflows:write]
      x-doc-page: /docs/guides/ai-generate-a-cascade
      x-mcp:
        tool: cascades_generate_workflow
        description: >-
          Build a cascade from a plain-English policy description using the Cascades
          AI builder, then create it (by default) so it is immediately runnable with
          cascades_trigger_workflow. Use this when the user describes a
          screening/decision policy in words rather than supplying a node graph.
          Returns the created cascade (with the AI-generated name and any warnings);
          pass create:false to preview the generated definition without saving it.
        annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
      requestBody:
        description: "A plain-language description of the screening policy to turn into a cascade."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowGenerateRequest" }
            examples:
              create:
                summary: Generate and save
                value:
                  prompt: "Pull an Equifax credit report. Approve if FICO is at least 650; otherwise send an adverse-action notice for review."
                  use_case: "tenant_screening"
              preview:
                summary: Preview only
                value:
                  prompt: "Pull an Equifax credit report. Approve if FICO is at least 650; otherwise send an adverse-action notice for review."
                  create: false
      responses:
        "201":
          description: Generated and saved. `generation` describes the AI pass; `note` appears for sandbox accounts (draft).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Workflow"
                      - type: object
                        properties:
                          generation: { $ref: "#/components/schemas/CascadeGeneration" }
                      - $ref: "#/components/schemas/SandboxDraftNote"
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  id: 42
                  object: workflow
                  code: FCS-42
                  name: FICO 650 Screening
                  active: true
                  trigger_type: manual
                  use_case: tenant_screening
                  created_at: "2026-09-09T14:10:00Z"
                  updated_at: "2026-09-09T14:10:00Z"
                  generation: { name: FICO 650 Screening, model: claude-opus-4-8, repaired: false, warnings: [] }
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "200":
          description: "Preview (`create: false`). Nothing was saved."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CascadeGenerationPreview" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: "`validation_failed` (blank or over-long `prompt`, or the saved workflow failed validation) or `generation_failed` (the model could not produce a valid definition; `details` lists the validator's findings)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                generation_failed:
                  value:
                    error:
                      code: generation_failed
                      message: The generated cascade did not pass validation.
                      details:
                        - { field: definition, message: "Decision node \"step_score_check\" uses operator \">=\", which is not valid." }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/generation_failed
                prompt_blank:
                  value:
                    error:
                      code: validation_failed
                      message: A prompt is required.
                      details:
                        - { field: prompt, message: "can't be blank" }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: "`designer_busy` — every generation slot is in use. Retry after a few seconds."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: designer_busy
                  message: The cascade designer is busy right now. Please try again in a moment.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/designer_busy

  /workflows/{id}:
    parameters:
      - $ref: "#/components/parameters/WorkflowIdParam"
    get:
      operationId: getWorkflow
      tags: [workflows]
      summary: Show a workflow
      description: Returns the workflow including its full `definition`. Requires `workflows:read`.
      x-scopes: [workflows:read]
      x-doc-page: /docs/concepts/cascades
      x-mcp:
        tool: cascades_get_workflow
        description: >-
          Fetch one cascade by id, including its full node graph (`definition`). Use
          this to read a cascade before updating it with cascades_update_workflow, or
          to inspect how it is built. Only cascades owned by the API key's account are
          returned (a foreign or deleted id 404s).
        annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
      responses:
        "200":
          description: Workflow with definition
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WorkflowDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateWorkflow
      tags: [workflows]
      summary: Update a workflow
      description: |
        Partial update: only the keys present in the body change. To edit the
        graph, read it with `getWorkflow`, modify the array, and send the full
        `definition` back. `PUT /workflows/{id}` is accepted as an alias with
        identical (partial) semantics.

        Setting `active: true` from an account without production access is
        refused with `403 activation_forbidden` rather than silently
        dropped. Requires `workflows:write`.
      x-scopes: [workflows:write]
      x-doc-page: /docs/concepts/cascades
      x-mcp:
        tool: cascades_update_workflow
        description: >-
          Update an existing cascade. PARTIAL update — only the fields you pass
          change; omitted fields keep their current value. To edit the node graph,
          first read it with cascades_get_workflow, modify the `definition`, then
          pass the full new array back. Requires the workflows:write scope; you can
          only update cascades owned by the API key's account.
        annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
      requestBody:
        description: "The fields to change. Omitted fields keep their current value."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowUpdateRequest" }
            examples:
              rename:
                summary: Rename only
                value: { name: "Tenant Screening — Premium" }
              deactivate:
                summary: Make it a draft
                value: { active: false }
              replace_definition:
                summary: Replace the node graph
                value:
                  definition:
                    - { id: "step_applicant", type: "ApplicantSource", name: "Applicant", next_step: ["step_approve"] }
                    - { id: "step_approve", type: "Action", name: "Approve", action_type: "Approve" }
      responses:
        "200":
          description: Updated workflow with definition
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WorkflowDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400":
          description: "`malformed_body`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `activation_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                activation_forbidden:
                  value:
                    error:
                      code: activation_forbidden
                      message: Activating a cascade requires production access. It stays a draft, which runs in test mode.
                      details:
                        - { field: active, message: requires production access }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/activation_forbidden
                insufficient_scope:
                  value:
                    error:
                      code: insufficient_scope
                      message: This token does not have the required scope.
                      details:
                        - { field: scope, message: "workflows:write" }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/insufficient_scope
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`validation_failed`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The workflow could not be updated.
                  details:
                    - { field: definition, message: can only have one Applicant Source node }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteWorkflow
      tags: [workflows]
      summary: Delete a workflow
      description: |
        Soft-deletes the cascade. It disappears from `listWorkflows`, can no
        longer be triggered, and its id returns `404` from then on. Existing
        executions and their artifacts are retained. Requires `workflows:write`.
      x-scopes: [workflows:write]
      x-doc-page: /docs/concepts/cascades
      x-mcp:
        tool: cascades_delete_workflow
        description: >-
          Delete a cascade (soft-delete). Afterwards it no longer appears in
          cascades_list_workflows and cannot be triggered. Requires the
          workflows:write scope; only cascades owned by the API key's account can be
          deleted. Confirm with the user before deleting.
        annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
      responses:
        "204": { description: Deleted (no body) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /workflows/{id}/test_cases:
    get:
      operationId: listWorkflowTestCases
      tags: [workflows]
      summary: List sandbox test cases for a workflow
      description: |
        The synthetic subjects registered in the CRS Testcase Service for
        this cascade's product nodes (credit, criminal, eviction …). Each
        carries a ready-to-run `payload` shaped for `triggerWorkflow`, so a
        test-mode run needs no hand-written applicant data. A test-mode
        trigger for a subject registered for every product node in the
        cascade replays its authored bureau responses at no charge
        (`input_context.sandbox_replay` is `true`); a subject that covers
        only some of the products gets schema mocks like any other applicant
        (set `payload.scenario` to `fail_path` to exercise decline branches).
        Live-mode sandbox runs replay registered subjects too.
        A credit case's own criteria carry only name and SSN, so blank
        fields (`dob`, the `property` address) are filled from the same
        subject's other registry cases, such as its criminal or eviction
        case, whichever products the cascade uses. Without them CRS refuses
        the credit order.
        Only the cases authored for Cascades (`case_name` starts with
        `Cascades_`) are listed. The registry's other fixtures mostly lack the
        first name and address CRS needs, so their runs would fail at the
        credit step.
        Best-effort: an unreachable registry yields an empty list, never a
        5xx. Results are cached for a few minutes. Requires `workflows:read`.
      x-scopes: [workflows:read]
      x-doc-page: /docs/sandbox/test-identities
      x-mcp:
        tool: cascades_list_test_cases
        description: >-
          List the pre-registered sandbox test-case subjects for a cascade — the
          synthetic applicants the CRS Testcase Service has authored for that
          cascade's products (e.g. specific credit-score bands). Use this when asked
          to "run the test cases", "try a sample applicant", or to sanity-check a
          cascade without hand-writing applicant data. Each result carries a
          ready-to-run `payload` (applicant + property) you pass straight to
          cascades_trigger_workflow with execution_mode "test". Returns an empty list
          when the cascade's products have no registered subjects.
        annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
      parameters:
        - $ref: "#/components/parameters/WorkflowIdParam"
      responses:
        "200":
          description: Test-case list (not paginated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/TestCaseList" }
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  object: test_case_list
                  workflow_id: 27
                  test_cases:
                    - id: 73
                      case_name: "Cascades_XProduct_Approve_EFX"
                      product_code: EQF1003
                      payload:
                        applicant: { first_name: AVERY, last_name: CLARKSON, middle_name: "", name: "AVERY CLARKSON", email: "avery.clarkson@example.com", phone: "", ssn: "666900001", dob: "1982-01-01" }
                        property: { address: "1803 Norma", unit: "", city: Cottonwood, state: CA, zip: "91502", full_address: "1803 Norma, Cottonwood, CA 91502" }
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /workflows/{id}/trigger:
    post:
      operationId: triggerWorkflow
      tags: [workflows]
      summary: Trigger a workflow
      description: |
        Starts an execution and returns `202` immediately; the run proceeds
        asynchronously. Poll `getExecution` until `status` is terminal
        (`completed`, `failed`, `cancelled`, `skipped`) or `awaiting_review`,
        or subscribe to `execution.completed` / `execution.failed`.

        **Test vs live.** `execution_mode` defaults to `test`, which uses
        synthetic vendor data and never bills — and works on drafts
        (`active: false`), so you can exercise a cascade before activating
        it. `live` requires an account with production access
        (`403 live_mode_forbidden` otherwise) **and** an active cascade
        (`409 workflow_inactive` otherwise).

        **Idempotency.** `Idempotency-Key` is required
        (`422 missing_idempotency_key` if absent). Race-safe under concurrent
        identical submissions: the loser receives the same execution as the
        winner. A reused key with a different body returns
        `409 idempotency_conflict`. Requires `executions:write`.
      x-scopes: [executions:write]
      x-idempotent: true
      x-async: { poll: getExecution }
      x-doc-page: /docs/concepts/executions
      x-mcp:
        tool: cascades_trigger_workflow
        description: >-
          Run a saved cascade for an applicant — i.e. start a decision. POSTs to
          /api/v1/workflows/:id/trigger and returns the created execution, which
          starts in status "pending" (the run is async). After calling this, poll
          cascades_get_execution with the returned execution id until status is
          terminal (completed / failed / cancelled / awaiting_review).
          execution_mode defaults to "test", which also works on draft cascades;
          "live" requires a production-approved account and an active cascade and is
          otherwise rejected.
        annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
      parameters:
        - $ref: "#/components/parameters/WorkflowIdParam"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        description: "The applicant payload and the execution mode for this run."
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TriggerRequest" }
            example:
              payload:
                applicant:
                  first_name: "Jordan"
                  last_name: "Lee"
                  email: "jordan@example.com"
              execution_mode: "test"
              external_reference: "rv-app-20491"
      responses:
        "202":
          description: Execution accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TriggerResponse" }
              example:
                data:
                  id: 1842
                  object: execution
                  workflow_id: 27
                  status: pending
                  execution_mode: test
                  execution_key: "api_client:12:3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  external_reference: rv-app-20491
                  needs_human_review: false
                  review_status: none
                  started_at: null
                  completed_at: null
                  created_at: "2026-09-09T14:00:00Z"
                  updated_at: "2026-09-09T14:00:00Z"
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "400":
          description: "`malformed_body`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                live_mode_forbidden:
                  value:
                    error:
                      code: live_mode_forbidden
                      message: Live mode requires production access.
                      details:
                        - { field: execution_mode, message: live mode requires production access }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/live_mode_forbidden
                insufficient_scope:
                  value:
                    error:
                      code: insufficient_scope
                      message: This token does not have the required scope.
                      details:
                        - { field: scope, message: "executions:write" }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/insufficient_scope
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`idempotency_conflict` or `workflow_inactive` (live trigger on a draft)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                idempotency_conflict:
                  value:
                    error:
                      code: idempotency_conflict
                      message: The Idempotency-Key has already been used with a different request payload.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/idempotency_conflict
                workflow_inactive:
                  value:
                    error:
                      code: workflow_inactive
                      message: Draft workflows run in test mode only. Activate the workflow before triggering it live.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/workflow_inactive
        "422":
          description: "`missing_idempotency_key` or `validation_failed`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                missing_idempotency_key:
                  value:
                    error:
                      code: missing_idempotency_key
                      message: Idempotency-Key header is required.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/missing_idempotency_key
                validation_failed:
                  value:
                    error:
                      code: validation_failed
                      message: The request could not be processed.
                      details:
                        - { field: execution_mode, message: must be test or live }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  # ==========================================================================
  # Workflow templates
  # ==========================================================================
  /workflow_templates:
    get:
      operationId: listWorkflowTemplates
      tags: [workflow_templates]
      summary: List published templates
      description: Read-only catalog, ordered by name. Drafts and inactive templates are
        never exposed via the API. Requires `workflow_templates:read`.
      x-scopes: [workflow_templates:read]
      x-doc-page: /docs/concepts/cascades
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[featured]", in: query, schema: { type: boolean } }
        - { name: "filter[category]", in: query, schema: { type: string } }
      responses:
        "200":
          description: Template list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WorkflowTemplate" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /workflow_templates/{id}:
    get:
      operationId: getWorkflowTemplate
      tags: [workflow_templates]
      summary: Show a template (with definition)
      description: Returns the template including its `definition`, which you can pass to `createWorkflow`. Requires `workflow_templates:read`.
      x-scopes: [workflow_templates:read]
      x-doc-page: /docs/concepts/cascades
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Template detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WorkflowTemplateDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ==========================================================================
  # Executions
  # ==========================================================================
  /executions:
    get:
      operationId: listExecutions
      tags: [executions]
      summary: List executions
      description: |
        Executions started by the calling api_client, newest first. CSV
        filters (`status`, `review_status`) accept several comma-separated
        values; an unknown value returns `422`. Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/executions
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[workflow_id]",         in: query, schema: { type: integer } }
        - { name: "filter[status]",              in: query, description: "CSV of `queued, pending, running, waiting, awaiting_review, completed, failed, cancelled, skipped`", schema: { type: string }, example: "completed,failed" }
        - { name: "filter[needs_human_review]",  in: query, schema: { type: boolean } }
        - { name: "filter[review_status]",       in: query, description: "CSV of `none, in_review, approved, denied`", schema: { type: string } }
        - { name: "filter[batch_execution_id]",  in: query, schema: { type: integer } }
        - { name: "filter[execution_key]",       in: query, description: "Exact match on the namespaced idempotency key.", schema: { type: string } }
        - { name: "filter[external_reference]",  in: query, schema: { type: string } }
      responses:
        "200":
          description: Execution list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Execution" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}:
    get:
      operationId: getExecution
      tags: [executions]
      summary: Show an execution
      description: |
        Returns the detail view (`Execution` + `input_context`). Tokens with
        `executions:read:full` additionally receive `step_outputs` and
        `failed_steps`, which typically contain applicant PII and vendor
        responses. This is the endpoint to poll after `triggerWorkflow` and
        `reEvaluateExecution`. Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/executions
      x-mcp:
        tool: cascades_get_execution
        description: >-
          Fetch a single cascade execution by id to check its status and results. Use
          this to poll after cascades_trigger_workflow until the run reaches a
          terminal status, and to read the decision: with full read access the
          response includes `step_outputs` (the per-step results) and
          `input_context`. Statuses: queued / pending / running / waiting /
          awaiting_review / completed / failed / cancelled / skipped.
        annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      responses:
        "200":
          description: Execution detail (or full detail if scope allows)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    anyOf:
                      - $ref: "#/components/schemas/ExecutionFull"
                      - $ref: "#/components/schemas/ExecutionDetail"
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  id: 1842
                  object: execution
                  workflow_id: 27
                  status: completed
                  execution_key: "api_client:12:3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  external_reference: rv-app-20491
                  needs_human_review: false
                  review_status: none
                  assigned_to_user_id: null
                  started_at: "2026-09-09T14:00:01Z"
                  completed_at: "2026-09-09T14:00:09Z"
                  created_at: "2026-09-09T14:00:00Z"
                  updated_at: "2026-09-09T14:00:09Z"
                  input_context:
                    applicant: { first_name: Jordan, last_name: Lee, email: jordan@example.com }
                    execution_mode: test
                    submitted_by_user_id: 5
                    user_approved: false
                    external_reference: rv-app-20491
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/cancel:
    post:
      operationId: cancelExecution
      tags: [executions]
      summary: Cancel an execution
      description: |
        Cancels a `pending`, `running`, or `waiting` execution, any
        outstanding waits, and any unresolved adverse-action drafts. The
        engine notices between nodes and stops dispatching further work, so
        a node already in flight may finish. Requires `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/executions
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      responses:
        "202":
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ExecutionDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`execution_not_cancellable` (already terminal or awaiting review)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: execution_not_cancellable
                  message: Execution is already completed.
                  details:
                    - { field: status, message: completed }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/execution_not_cancellable
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/re_evaluations:
    post:
      operationId: reEvaluateExecution
      tags: [executions]
      summary: Re-evaluate an execution
      description: |
        Creates a new execution that re-runs the source's `input_context`
        against the source's snapshotted definition (NOT the workflow's
        current definition) and returns `202`; poll `getExecution` on the
        new id. The source must belong to an active workflow, and re-running
        a live-mode execution requires production access.

        `Idempotency-Key` is required and is namespaced per source
        execution, so the same key can be reused across different sources.
        Requires `executions:write`.
      x-scopes: [executions:write]
      x-idempotent: true
      x-async: { poll: getExecution }
      x-doc-page: /docs/concepts/executions
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Re-evaluation accepted (the NEW execution)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ExecutionDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: live_mode_forbidden
                  message: Re-running a live-mode execution requires production access.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/live_mode_forbidden
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`workflow_inactive`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: workflow_inactive
                  message: Only active workflows can be re-evaluated through the public API.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/workflow_inactive
        "422":
          description: "`missing_idempotency_key`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: missing_idempotency_key
                  message: Idempotency-Key header is required.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/missing_idempotency_key
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/review_decisions:
    post:
      operationId: submitReviewDecision
      tags: [executions]
      summary: Approve or deny a paused review
      description: |
        Records the decision on an execution that is `awaiting_review` (or
        `waiting`) with `needs_human_review: true`, auto-claims it for the
        caller if unassigned, and resumes the chosen branch. Test-mode
        executions resume inline and return `200` with the final state;
        live-mode executions enqueue the resume and return `202` with
        `resume_enqueued: true`. Requires `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/human-review
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      requestBody:
        description: "The review decision (approve or deny) for the paused step."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewDecisionRequest" }
            example:
              decision: "approved"
              note: "Manual review confirmed; OFAC clean."
      responses:
        "200":
          description: Review decided and downstream branch ran inline (test mode)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/ExecutionDetail"
                      - type: object
                        properties:
                          resume_enqueued: { type: boolean, example: false }
                  meta: { $ref: "#/components/schemas/Meta" }
        "202":
          description: Review decided and downstream branch enqueued (live mode)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/ExecutionDetail"
                      - type: object
                        properties:
                          resume_enqueued: { type: boolean, example: true }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope`, `review_assigned_elsewhere` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                review_assigned_elsewhere:
                  value:
                    error:
                      code: review_assigned_elsewhere
                      message: This review is assigned to another user.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/review_assigned_elsewhere
                live_mode_forbidden:
                  value:
                    error:
                      code: live_mode_forbidden
                      message: Live-mode review decisions require production access.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/live_mode_forbidden
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`review_not_pending` or `review_already_decided`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                review_not_pending:
                  value:
                    error:
                      code: review_not_pending
                      message: Execution is not awaiting human review.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/review_not_pending
                review_already_decided:
                  value:
                    error:
                      code: review_already_decided
                      message: Review was already approved.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/review_already_decided
        "422":
          description: "`validation_failed` — `decision` must be `approved` or `denied`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The request could not be processed.
                  details:
                    - { field: decision, message: must be approved or denied }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/review_events:
    get:
      operationId: listReviewEvents
      tags: [executions]
      summary: List review audit events
      description: Returns flagged / assigned / unassigned / approved /
        denied / note_added / re_evaluated events, plus the
        adverse_action_drafted / adverse_action_sent / adverse_action_skipped /
        adverse_action_replayed notice events, ordered by created_at.
        Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/human-review
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Audit trail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ReviewEvent" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/review_assignment:
    post:
      operationId: assignReview
      tags: [executions]
      summary: Claim or unassign a review
      description: |
        `assignee: "me"` claims the review for the calling api_user;
        `assignee: null` releases it. An `assigned` / `unassigned` audit event
        is recorded when the assignee actually changes. Requires
        `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/human-review
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      requestBody:
        description: "Whether to claim the review or unassign it."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewAssignmentRequest" }
            examples:
              claim:
                summary: Claim the review for this api_user
                value: { assignee: "me" }
              unassign:
                summary: Release the review (no assignee)
                value: { assignee: null }
      responses:
        "200":
          description: Updated assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ExecutionDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`validation_failed` — `assignee` must be `\"me\"` or `null`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The request could not be processed.
                  details:
                    - { field: assignee, message: "must be 'me' to claim or null to unassign" }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/review_notes:
    post:
      operationId: addReviewNote
      tags: [executions]
      summary: Append a review note
      description: Records a `note_added` audit event on the execution. Requires `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/human-review
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      requestBody:
        description: "The note to append to the review."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewNoteRequest" }
            example:
              note: "Pulled credit report manually; matches OFAC cleanly."
      responses:
        "201":
          description: Note recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ReviewEvent" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`validation_failed` — blank `note`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The request could not be processed.
                  details:
                    - { field: note, message: must not be blank }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/review_bulk_actions:
    post:
      operationId: bulkReviewActions
      tags: [executions]
      summary: Bulk approve / deny / assign_to_me
      description: |
        Always asynchronous. Approve/deny enqueue the resume per execution
        (`enqueued` counts them). Cap of 25 ids per request. Ids that do not
        resolve in the caller's scope are reported as `not_found` in
        `skipped` so existence is not leaked. Requires `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/human-review
      requestBody:
        description: "The action to apply and the executions to apply it to."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkReviewRequest" }
            example:
              execution_ids: [1842, 1843, 1844, 1845, 1846]
              bulk_action: "approve"
              note: "Manual review batch 2026-09-09 — OFAC and credit clean."
      responses:
        "202":
          description: Bulk action processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BulkReviewResult" }
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  object: bulk_review_result
                  applied: 4
                  enqueued: 4
                  skipped:
                    - { id: 1846, reason: not_found }
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: "`validation_failed` — unknown `bulk_action`, empty `execution_ids`, or more than 25 ids"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: Bulk action exceeds the per-request maximum.
                  details:
                    - { field: execution_ids, message: max 25 ids per request }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/report_artifacts:
    get:
      operationId: listReportArtifacts
      tags: [executions]
      summary: List credit-report PDFs for an execution
      description: |
        Metadata only; the whole list is returned in one response (not
        paginated — `page` / `per_page` are ignored). Download each file
        with `getReportArtifact`, which needs `executions:read:full`.
        Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/executions
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      responses:
        "200":
          description: Artifact list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Artifact" }
                  meta: { $ref: "#/components/schemas/StaticCollectionMeta" }
              example:
                data:
                  - { id: 9911, object: report_artifact, execution_id: 1842, filename: Equifax_Credit_Report.pdf, content_type: application/pdf, byte_size: 184223, generated_at: "2026-09-09T14:00:07Z" }
                meta: { object: report_artifact, total_count: 1, page: 1, per_page: 1, total_pages: 1, request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/report_artifacts/{artifact_id}:
    get:
      operationId: getReportArtifact
      tags: [executions]
      summary: Download a credit-report PDF
      description: Returns the binary PDF as an attachment. Requires `executions:read:full`
        because the contents are raw applicant data.
      x-scopes: ["executions:read:full"]
      x-doc-page: /docs/concepts/executions
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
        - $ref: "#/components/parameters/ArtifactIdParam"
      responses:
        "200":
          description: Binary PDF
          headers:
            Content-Disposition:
              schema: { type: string }
              example: 'attachment; filename="Equifax_Credit_Report.pdf"'
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/notice_artifacts:
    get:
      operationId: listNoticeArtifacts
      tags: [executions]
      summary: List adverse-action notice PDFs for an execution
      description: |
        Metadata only; not paginated (`page` / `per_page` are ignored).
        Download each file with `getNoticeArtifact`, which needs
        `executions:read:full`. Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/adverse-action
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
      responses:
        "200":
          description: Artifact list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Artifact" }
                  meta: { $ref: "#/components/schemas/StaticCollectionMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /executions/{id}/notice_artifacts/{artifact_id}:
    get:
      operationId: getNoticeArtifact
      tags: [executions]
      summary: Download an adverse-action notice PDF
      description: Returns the binary PDF as an attachment. Requires `executions:read:full`.
      x-scopes: ["executions:read:full"]
      x-doc-page: /docs/concepts/adverse-action
      parameters:
        - $ref: "#/components/parameters/ExecutionIdParam"
        - $ref: "#/components/parameters/ArtifactIdParam"
      responses:
        "200":
          description: Binary PDF
          headers:
            Content-Disposition:
              schema: { type: string }
              example: 'attachment; filename="Adverse_Action_Notice.pdf"'
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ==========================================================================
  # Batches
  # ==========================================================================
  /workflows/{id}/batches:
    post:
      operationId: createBatch
      tags: [batches]
      summary: Create a batch from N rows
      description: |
        Pre-creates the batch and N `queued` child executions in a single
        transaction and returns `202`; the rows are then processed
        asynchronously. Poll `getBatch` for the counters and
        `listBatchExecutions` for per-row status. Cap of 500 rows per
        request; every row must be an object. `live` requires production
        access (`403 live_mode_forbidden`).

        Batches need an **active** cascade, in test mode too. On a draft
        (`active: false`) every batch create returns `409 workflow_inactive`
        before the rows, the mode or the `Idempotency-Key` are checked. A
        single test-mode `triggerWorkflow` on a draft still runs (`202`).
        A sandbox account (not approved for production) cannot activate a
        cascade, so it gets this 409 on every batch. To run a batch, an
        approved account activates the cascade in the app, or with
        `PATCH /workflows/{id}` and `active: true` from a key made at
        /api-keys (docs keys cannot activate).

        `Idempotency-Key` is required (`422 missing_idempotency_key`). The
        key is compared against the resolved `execution_mode`,
        `external_reference` and a SHA-256 digest of the rows, so reusing a
        key with different content returns `409 idempotency_conflict`.
        Requires `executions:write`.
      x-scopes: [executions:write]
      x-idempotent: true
      x-async: { poll: getBatch }
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/WorkflowIdParam"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        description: "The rows to run, one applicant payload per row."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchCreateRequest" }
            example:
              rows:
                - external_reference: "row-1"
                  applicant: { first_name: "Ava", last_name: "Stone", email: "ava@example.com" }
                - external_reference: "row-2"
                  applicant: { first_name: "Ben", last_name: "Park", email: "ben@example.com" }
              execution_mode: "test"
              external_reference: "rentvine-batch-001"
      responses:
        "202":
          description: Batch accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  id: 77
                  object: batch
                  workflow_id: 27
                  status: pending
                  execution_mode: test
                  external_reference: rentvine-batch-001
                  total_count: 2
                  completed_count: 0
                  failed_count: 0
                  started_at: null
                  completed_at: null
                  created_at: "2026-09-09T14:20:00Z"
                  updated_at: "2026-09-09T14:20:00Z"
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "400":
          description: "`malformed_body`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: live_mode_forbidden
                  message: Live mode requires production access.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/live_mode_forbidden
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`workflow_inactive` (the cascade is a draft, in any mode) or `idempotency_conflict`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                workflow_inactive:
                  value:
                    error:
                      code: workflow_inactive
                      message: Only active workflows can run batches through the public API.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/workflow_inactive
                idempotency_conflict:
                  value:
                    error:
                      code: idempotency_conflict
                      message: The Idempotency-Key has already been used with a different request.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/idempotency_conflict
        "422":
          description: "`missing_idempotency_key` or `validation_failed` (empty `rows`, more than 500 rows, a non-object row, or a bad `execution_mode`)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                missing_idempotency_key:
                  value:
                    error:
                      code: missing_idempotency_key
                      message: Idempotency-Key header is required.
                      details: []
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/missing_idempotency_key
                too_many_rows:
                  value:
                    error:
                      code: validation_failed
                      message: Batch size exceeds the per-request maximum.
                      details:
                        - { field: rows, message: max 500 rows per batch }
                      request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                      doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches:
    get:
      operationId: listBatches
      tags: [batches]
      summary: List batches
      description: Batches created by the calling api_client, newest first. Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[status]",             in: query, description: "CSV of `pending, running, interrupted, completed, cancelled`", schema: { type: string } }
        - { name: "filter[workflow_id]",        in: query, schema: { type: integer } }
        - { name: "filter[external_reference]", in: query, schema: { type: string } }
      responses:
        "200":
          description: Batch list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}:
    get:
      operationId: getBatch
      tags: [batches]
      summary: Show a batch
      description: |
        Batch status and counters. This is the endpoint to poll after
        `createBatch`, `resumeBatch` and `retryFailedBatch`; the batch is
        finished when `status` is `completed` or `cancelled` (`interrupted`
        means it stopped early and can be resumed). Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
      responses:
        "200":
          description: Batch detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/executions:
    get:
      operationId: listBatchExecutions
      tags: [batches]
      summary: List child executions of a batch
      description: |
        Child rows ordered by `row_index`. Tokens with `executions:read:full`
        additionally receive `applicant_name` and `applicant_email`.
        Requires `executions:read`.
      x-scopes: [executions:read]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[status]", in: query, description: "CSV of execution statuses", schema: { type: string } }
      responses:
        "200":
          description: Child execution list (row_index ordered)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BatchExecutionRow" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/cancel:
    post:
      operationId: cancelBatch
      tags: [batches]
      summary: Cancel a batch
      description: Halts dispatch and propagates `cancelled` to in-flight
        and queued child executions. Requires `executions:write`.
      x-scopes: [executions:write]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
      responses:
        "202":
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`batch_not_cancellable` (not `pending`, `running` or `interrupted`)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: batch_not_cancellable
                  message: Batch is in status completed and cannot be cancelled.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/batch_not_cancellable
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/resume:
    post:
      operationId: resumeBatch
      tags: [batches]
      summary: Resume an interrupted batch
      description: Re-queues remaining children and restarts processing;
        poll `getBatch`. Re-checks `workflow_inactive` and
        `live_mode_forbidden` at resume time. Requires `executions:write`.
      x-scopes: [executions:write]
      x-async: { poll: getBatch }
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
      responses:
        "202":
          description: Resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`batch_not_resumable` or `workflow_inactive`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: batch_not_resumable
                  message: Batch is in status running and cannot be resumed.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/batch_not_resumable
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/retry_failed:
    post:
      operationId: retryFailedBatch
      tags: [batches]
      summary: Retry failed and skipped rows
      description: Bumps `retry_count` on failed rows under the per-row
        cap, requeues skipped rows, clears their stale terminal state, and
        restarts processing; poll `getBatch`. Re-checks `workflow_inactive`
        and `live_mode_forbidden`. Requires `executions:write`.
      x-scopes: [executions:write]
      x-async: { poll: getBatch }
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
      responses:
        "202":
          description: Retry started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Batch" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`insufficient_scope` or `live_mode_forbidden`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`batch_not_retryable` or `workflow_inactive`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: batch_not_retryable
                  message: Batch is in status completed and has no retryable rows.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/batch_not_retryable
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/exports.csv:
    get:
      operationId: exportBatch
      tags: [batches]
      summary: Download flat results CSV
      description: |
        One row per child execution by default. Pass `?include=all` to
        include re-evaluation revisions. The same route without an
        extension — `GET /batches/{id}/exports` — also returns CSV (it is
        the default format); `.zip` selects the structured archive
        (`exportBatchZip`). Requires `executions:read:full` because the CSV
        contains raw applicant data.
      x-scopes: ["executions:read:full"]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
        - { name: include, in: query, description: "`all` includes every re-evaluation revision of each row.", schema: { type: string, enum: [all] } }
      responses:
        "200":
          description: CSV
          headers:
            Content-Disposition:
              schema: { type: string }
              example: 'attachment; filename="batch_77_results_2026-09-09.csv"'
          content:
            text/csv:
              schema: { type: string, description: UTF-8 CSV text with a header row. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/exports.zip:
    get:
      operationId: exportBatchZip
      tags: [batches]
      summary: Download structured ZIP of batch results
      description: |
        ZIP containing an error report, per-product CSVs, JSONs, and
        report PDFs. Pass `?include=all` for revision history. A PDF whose
        file is no longer stored is left out and named in `MissingFiles.txt`.
        Requires `executions:read:full`.
      x-scopes: ["executions:read:full"]
      x-doc-page: /docs/concepts/batches
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
        - { name: include, in: query, description: "`all` includes every re-evaluation revision of each row.", schema: { type: string, enum: [all] } }
      responses:
        "200":
          description: ZIP
          headers:
            Content-Disposition:
              schema: { type: string }
              example: 'attachment; filename="batch_77_results_2026-09-09.zip"'
          content:
            application/zip:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /batches/{id}/adverse_action_notices.zip:
    get:
      operationId: downloadBatchAdverseActionNotices
      tags: [batches]
      summary: Download all adverse-action notice PDFs as a ZIP
      description: One entry per notice, prefixed with the applicant name. A notice whose file is no longer stored is left out and named in `MissingFiles.txt`. Returns `404 not_found` when no notices exist for the batch. Requires `executions:read:full`.
      x-scopes: ["executions:read:full"]
      x-doc-page: /docs/concepts/adverse-action
      parameters:
        - $ref: "#/components/parameters/BatchIdParam"
      responses:
        "200":
          description: ZIP
          headers:
            Content-Disposition:
              schema: { type: string }
              example: 'attachment; filename="adverse_action_notices_batch_77.zip"'
          content:
            application/zip:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404":
          description: Batch not found, or no notices have been generated for it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: not_found
                  message: No adverse-action notices have been generated for this batch.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/not_found
        "429": { $ref: "#/components/responses/RateLimited" }

  # ==========================================================================
  # Webhooks
  # ==========================================================================
  /webhook_subscriptions:
    get:
      operationId: listWebhookSubscriptions
      tags: [webhooks]
      summary: List webhook subscriptions
      description: Subscriptions belonging to the calling api_client, newest first. `signing_secret` is never included. Requires `webhooks:read`.
      x-scopes: [webhooks:read]
      x-doc-page: /docs/concepts/webhooks
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Subscription list (signing_secret never exposed here)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookSubscription" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createWebhookSubscription
      tags: [webhooks]
      summary: Create a webhook subscription
      description: |
        Returns the `signing_secret` EXACTLY ONCE. Store it; subsequent
        reads never re-expose it (use `rotateWebhookSecret` if you lose it).
        The URL is validated against the SSRF guard (no private / loopback /
        cloud-metadata addresses, no blocked service ports). An empty or
        omitted `event_filter` subscribes to every event. Requires
        `webhooks:write`.
      x-scopes: [webhooks:write]
      x-doc-page: /docs/concepts/webhooks
      requestBody:
        description: "The endpoint URL and the events to deliver to it."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookSubscriptionCreateRequest" }
            example:
              url: "https://hooks.partner.example/cascades"
              event_filter: ["execution.completed", "execution.failed", "adverse_action.completed"]
      responses:
        "201":
          description: Created (response includes signing_secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscriptionWithSecret" }
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  id: 9
                  object: webhook_subscription
                  url: https://hooks.partner.example/cascades
                  event_filter: [execution.completed, execution.failed, adverse_action.completed]
                  active: true
                  last_delivered_at: null
                  created_at: "2026-09-09T13:00:00Z"
                  updated_at: "2026-09-09T13:00:00Z"
                  signing_secret: whsec_4f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9
                meta: { request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: "`validation_failed` — bad URL, non-public host, or an unknown event name"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: validation_failed
                  message: The request could not be processed.
                  details:
                    - { field: event_filter, message: "contains unknown event: execution.started" }
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/validation_failed
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_subscriptions/{id}:
    parameters:
      - $ref: "#/components/parameters/WebhookSubscriptionIdParam"
    get:
      operationId: getWebhookSubscription
      tags: [webhooks]
      summary: Show a webhook subscription
      description: Requires `webhooks:read`.
      x-scopes: [webhooks:read]
      x-doc-page: /docs/concepts/webhooks
      responses:
        "200":
          description: Subscription (no signing_secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateWebhookSubscription
      tags: [webhooks]
      summary: Update a webhook subscription
      description: |
        Partial update of `url`, `event_filter` and `active`; omitted keys
        keep their value. `PUT /webhook_subscriptions/{id}` is accepted as an
        alias with the same partial semantics. The response never includes
        the `signing_secret`. Requires `webhooks:write`.
      x-scopes: [webhooks:write]
      x-doc-page: /docs/concepts/webhooks
      requestBody:
        description: "The fields to change. Omitted fields keep their current value."
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookSubscriptionUpdateRequest" }
            examples:
              narrow_filter:
                summary: Subscribe to fewer events
                value:
                  event_filter: ["adverse_action.completed"]
              pause:
                summary: Pause the subscription without deleting it
                value: { active: false }
              new_url:
                summary: Move the subscription to a new URL
                value: { url: "https://hooks.partner.example/cascades/v2" }
      responses:
        "200":
          description: Updated (no signing_secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteWebhookSubscription
      tags: [webhooks]
      summary: Delete a webhook subscription
      description: Permanently deletes the subscription and its delivery log. Requires `webhooks:write`.
      x-scopes: [webhooks:write]
      x-doc-page: /docs/concepts/webhooks
      responses:
        "204": { description: Deleted (no body) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_subscriptions/{id}/rotate_secret:
    post:
      operationId: rotateWebhookSecret
      tags: [webhooks]
      summary: Rotate the signing secret
      description: |
        Generates a new `signing_secret` (returned once, in this response)
        and preserves the previous one for 24 hours so deliveries during
        your deploy window ship with both `v1=` (new) and `v0=` (old)
        signatures. After the grace window only `v1=` ships. Requires
        `webhooks:write`.
      x-scopes: [webhooks:write]
      x-doc-page: /docs/concepts/webhooks
      parameters:
        - $ref: "#/components/parameters/WebhookSubscriptionIdParam"
      responses:
        "200":
          description: New signing_secret returned (one-time)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscriptionWithSecret" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      summary: List webhook deliveries
      description: |
        Delivery attempts across all of the calling api_client's
        subscriptions, newest first. The event payload and the subscriber's
        response body are deliberately omitted. CSV filters accept several
        comma-separated values; unknown values return `422`. Requires
        `webhooks:read`.
      x-scopes: [webhooks:read]
      x-doc-page: /docs/concepts/webhooks
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - { name: "filter[webhook_subscription_id]", in: query, schema: { type: integer } }
        - { name: "filter[event_type]",              in: query, description: "CSV of event names", schema: { type: string }, example: "execution.completed,execution.failed" }
        - { name: "filter[status]",                  in: query, description: "CSV of `pending, retrying, succeeded, failed, dead`", schema: { type: string } }
      responses:
        "200":
          description: Delivery list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  meta: { $ref: "#/components/schemas/PaginatedMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_deliveries/{id}:
    get:
      operationId: getWebhookDelivery
      tags: [webhooks]
      summary: Show a webhook delivery
      description: Requires `webhooks:read`.
      x-scopes: [webhooks:read]
      x-doc-page: /docs/concepts/webhooks
      parameters:
        - $ref: "#/components/parameters/WebhookDeliveryIdParam"
      responses:
        "200":
          description: Delivery metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookDelivery" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_deliveries/{id}/retry:
    post:
      operationId: retryWebhookDelivery
      tags: [webhooks]
      summary: Manually retry a failed or dead delivery
      description: Resets the delivery to `pending` and re-enqueues it immediately,
        outside the automatic retry schedule. Refuses to re-deliver a row that
        already succeeded or is currently in flight. Requires `webhooks:write`.
      x-scopes: [webhooks:write]
      x-doc-page: /docs/concepts/webhooks
      parameters:
        - $ref: "#/components/parameters/WebhookDeliveryIdParam"
      responses:
        "202":
          description: Retry enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookDelivery" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`delivery_not_retryable`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              example:
                error:
                  code: delivery_not_retryable
                  message: Delivery is in status succeeded and cannot be retried.
                  details: []
                  request_id: 7d4c2f48-9a1b-4c3d-8e2f-1a2b3c4d5e6f
                  doc_url: https://cascades.sandbox.crs-data.com/docs/api/errors/delivery_not_retryable
        "429": { $ref: "#/components/responses/RateLimited" }
