openapi: 3.0.3
info:
  title: SOAT Tasks API
  version: 1.0.0
  description: >-
    API for managing tasks — durable, stateful work items bound to a workflow.
    A task moves between named states over time (including backward), driven by
    a mix of agents and humans, with a full, audited transition history.
  contact:
    name: SOAT Team
    url: https://github.com/ttoss/soat
servers:
  - url: '{baseUrl}'
    description: Base URL of your SOAT deployment (e.g. https://your-soat.com or http://localhost:5047)
    variables:
      baseUrl:
        description: The base URL of your SOAT deployment
        default: http://localhost:5047
tags:
  - name: Tasks
    description: Manage tasks and their transitions
security:
  - bearerAuth: []
paths:
  /api/v1/tasks:
    get:
      description: >-
        Lists tasks (the board query). Filter by workflow, state, status,
        automation status, assignee, or `metadata` — `GET
        /tasks?workflow_id=...&state=...` is one board column.
      tags:
        - Tasks
      summary: List tasks
      operationId: listTasks
      parameters:
        - name: project_id
          in: query
          required: false
          schema:
            type: string
        - name: workflow_id
          in: query
          required: false
          schema:
            type: string
        - name: state
          in: query
          required: false
          schema:
            type: string
        - name: status
          in: query
          required: false
          description: >-
            Filter by whether the task is open or closed. Repeat the parameter
            to OR values. A value outside the enum, empty string included, is a
            `400`.
          schema:
            type: array
            items:
              type: string
              enum: [open, closed]
          style: form
          explode: true
        - name: automation_status
          in: query
          required: false
          description: >-
            Filter by the current state's dispatch status. Repeat the parameter
            to OR values. `none` selects the tasks whose `automation_status` is
            `null` — the ones that never entered a state with an automation. It
            is a value a task really holds, so it is a value of the filter too;
            the parameter's own absence already means "every task".


            A value outside the enum, empty string included, is a `400`.
          schema:
            type: array
            items:
              type: string
              enum:
                - running
                - completed
                - failed
                - unrouted
                - paused
                - none
          style: form
          explode: true
          example:
            - running
        - name: assignee
          in: query
          required: false
          schema:
            type: string
        - $ref: './metadata-filters.yaml#/components/parameters/MetadataQuery'
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: A list of tasks
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Task'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    post:
      description: >-
        Creates a task bound to a workflow. By default the task is placed in
        the workflow's initial state; passing `state` places it directly in
        that named state instead — an alternate entry point for starting a
        task mid-flow (e.g. "a new recorte for an existing theme by id"),
        rather than re-submitting from the initial state and hoping a guard or
        similarity gate recognizes it. Entering the resulting state, initial
        or named, behaves identically: that state's `on_enter` automation
        fires and its `stalled_after` clock arms.
      tags:
        - Tasks
      summary: Create a task
      operationId: createTask
      parameters:
        - $ref: './idempotency.yaml#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTaskRequest'
      responses:
        '200':
          description: >-
            The idempotency key already names a task created by the same
            request; that task is returned as it is now and no second task is
            created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '201':
          description: Task created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          description: >-
            Bad request — invalid payload (`TASK_PAYLOAD_INVALID`), `state`
            does not name a declared state of the workflow
            (`TASK_STATE_NOT_FOUND`), or an invalid idempotency key
            (`VALIDATION_FAILED`)
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Workflow not found
        '409':
          description: >-
            The idempotency key names a task created by a different request
            (`IDEMPOTENCY_KEY_REUSED`)

  /api/v1/tasks/{task_id}:
    get:
      description: Retrieves a task, including its active dispatch and automation status.
      tags:
        - Tasks
      summary: Get a task
      operationId: getTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
    patch:
      description: >-
        Updates a task's payload, title, or assignee. `state` is never directly
        writable — move it with a transition; sending a `state` field is rejected
        as an unknown field (`VALIDATION_FAILED`). `payload` is shallow-merged over
        the existing payload (PATCH semantics): keys the request omits are
        preserved. The payload is caller-owned; the automation result lives in the
        read-only `last_result` field, which no patch can reach. The merged
        payload is validated against the workflow's `payload_schema`.
      tags:
        - Tasks
      summary: Update a task
      operationId: updateTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTaskRequest'
      responses:
        '200':
          description: Task updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          description: Bad request (invalid payload)
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
    delete:
      description: Deletes a task. Its transition history cascades.
      tags:
        - Tasks
      summary: Delete a task
      operationId: deleteTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Task deleted
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found

  /api/v1/tasks/{task_id}/transitions:
    get:
      description: >-
        Lists every transition of the workflow version the task is pinned to,
        with whether firing it now as the caller would be accepted and, when
        not, the error code firing it would answer. The guard runs with the
        caller as `principal`. A `requires_approval` transition is allowed
        before its guard passes: the guard runs when the approval resolves.
        Advisory: firing re-validates under the task's lock.
      tags:
        - Tasks
      summary: List a task's transitions
      operationId: listTaskTransitions
      parameters:
        - name: task_id
          in: path
          required: true
          description: The task whose transitions are listed.
          schema:
            type: string
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: The pinned version's transitions, in definition order
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TaskTransitionOption'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
    post:
      description: >-
        Fires a named transition on a task. The transition must exist in the
        workflow and be valid from the task's current state; its guard must pass.
        This is the single path every state change routes through. A transition
        declaring `requires_approval` does not move the task — it parks a pending
        ApprovalItem and returns the task with `pending_transition` set; the move
        applies only when the approval is approved.
      tags:
        - Tasks
      summary: Transition a task
      operationId: transitionTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
        - $ref: './idempotency.yaml#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransitionTaskRequest'
      responses:
        '200':
          description: The task after the transition
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          description: The transition does not exist, is not valid, or its guard rejected the move
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
        '409':
          description: >-
            A concurrent transition made this one invalid, the task is closed,
            or the idempotency key names a different request

  /api/v1/tasks/{task_id}/migrate:
    post:
      description: >-
        Moves an open task onto a newer version of its workflow. A state the
        target version also declares maps to itself; `state_map` maps a state
        it lacks. The payload is re-validated against the target
        `payload_schema`. The mapped state's `on_enter` is not fired: transition
        the task when you want the target's automation. A task with a dispatch
        in flight or a transition pending approval is refused, since both
        belong to the version they started under. Recorded in history as a row
        with `transition: null` and both versions.
      tags:
        - Tasks
      summary: Migrate a task to a newer workflow version
      operationId: migrateTask
      parameters:
        - name: task_id
          in: path
          required: true
          description: The task to migrate.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MigrateTaskRequest'
      responses:
        '200':
          description: The task on the target version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          description: >-
            The version does not exist or is not newer than the task's, the
            current state is unmapped, a mapped state is undeclared or
            terminal, or the payload fails the target `payload_schema`
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
        '409':
          description: >-
            The task is closed, has a transition pending approval, or has a
            dispatch in flight

  /api/v1/tasks/{task_id}/pause:
    post:
      description: >-
        Pauses a task's automation. While a pause is in force no state's
        `on_enter` dispatches and no retry chain continues — an agent
        generation, a tool call and a sub-orchestration alike — so the task
        stops spending without losing its place.

        A workflow has no run object, so this is the workflow half of
        pause-orchestration-run: the pause lands on the instance, which is the
        task. Transitions are deliberately still allowed — a move costs nothing
        while every dispatch it would start is suppressed — so a board stays
        usable under a pause. Entering a state whose dispatch is suppressed
        records `automation_status: paused`, which resume-task reads to know that
        state still owes its work.

        A dispatch already in flight is left to finish, and its outcome still
        routes; only what would start after it is suppressed.

        Idempotent: pausing an already-paused task answers with it unchanged.
      tags:
        - Tasks
      summary: Pause a task
      operationId: pauseTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PauseTaskRequest'
      responses:
        '200':
          description: The paused task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
        '409':
          description: The task is closed, so it has no automation left to pause

  /api/v1/tasks/{task_id}/resume:
    post:
      description: >-
        Lifts a task's pause. When the pause suppressed the current state's
        `on_enter` — `automation_status: paused` — that dispatch is started now,
        as the caller resuming rather than as whoever last moved the task. A
        state whose dispatch had already completed, or that declares none, is
        left alone, so a resume never re-spends work the pause did not stop.

        This is the only way a pause is lifted; a task that is merely idle is
        advanced by firing a transition instead.
      tags:
        - Tasks
      summary: Resume a task
      operationId: resumeTask
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The resumed task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
        '409':
          description: The task carries no pause to lift

  /api/v1/tasks/{task_id}/history:
    get:
      description: Returns the append-only transition history of a task.
      tags:
        - Tasks
      summary: Get task history
      operationId: getTaskHistory
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: The task's transition history, oldest first
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TaskTransition'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Task not found
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Task:
      type: object
      properties:
        id:
          type: string
        project_id:
          type: string
        workflow_id:
          type: string
        workflow_version:
          type: integer
          nullable: true
          description: >-
            The workflow version this task runs on, fixed when the task was
            created. Transitions, approval gates and payload validation all
            resolve through it, so editing the workflow never re-shapes a task
            already in flight. `null` for tasks created before pinning existed,
            which run on the live definition.
          example: 1
        title:
          type: string
        state:
          type: string
        status:
          type: string
          enum:
            - open
            - closed
        payload:
          type: object
          description: >-
            Caller-owned task data; input to guards (as `task.payload`) and
            dispatch mappings. The engine never writes into it except the
            workflow's declared `payload_writes`.
        metadata:
          description: >-
            The caller-owned key/value metadata supplied when the task was
            created, returned verbatim. Absent when the task was created without
            any. Unlike `payload` it is invisible to guards and to
            `payload_writes`, so it is the place for an attribution label rather
            than task data.
          example:
            tenant_account_id: '42'
            source: zendesk
          allOf:
            - $ref: './metadata.yaml#/components/schemas/MetadataBag'
        last_result:
          description: >-
            Server-owned. The result of the current state's last completed
            dispatch, overwritten on every dispatch. Read-only — exposed to
            transition guards and `on_complete`/`payload_writes` expressions as
            `task.last_result`, a namespace a caller cannot write. An `agent`
            dispatch's result is the generation's output plus `ai_provider_id`,
            the provider that served its `model` (null when the route that ran
            named no serving target).
        assignee:
          type: string
          nullable: true
        active_dispatch:
          type: object
          nullable: true
          description: >-
            { kind, id, status } of the current state's dispatch, if any. `kind`
            is `generation`, `orchestration_run` or `tool_call`; a `tool_call`
            always carries a null `id`, since a direct tool call leaves no
            addressable record. Carries an additional `attempt` (1-based) while
            the state's `on_enter.retry` policy is in effect.
        automation_status:
          type: string
          nullable: true
          enum:
            - running
            - completed
            - failed
            - unrouted
            - paused
            - null
          description: >-
            Status of the current state's dispatch. `null` until a state with an
            automation is entered. `paused` means an operator pause suppressed
            this state's `on_enter` before it ran, so resume-task will start it.
        pause_requested_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            When an operator paused this task's automation, or null when no
            pause is in force.
        pause_reason:
          type: string
          nullable: true
          description: The reason supplied with the pause, when one was.
        automation_chain_depth:
          type: integer
          description: >-
            Server-owned. How many machine-driven transitions have run
            back-to-back with no outside intervention — a dispatch outcome routed
            through `on_complete`/`on_failure`, or a `transition-task` call made
            by a dispatched run or agent with its run-as token. Any move by a
            person, a plain API key, or an approval resolution resets it to `0`.
            Once it would exceed the server's limit (`TASK_AUTOMATION_CHAIN_LIMIT`,
            default 50) the next such transition is refused with
            `TASK_AUTOMATION_CHAIN_LIMIT`, bounding a cycle composed across
            workflows and orchestrations.
        pending_transition:
          type: string
          nullable: true
          description: >-
            The name of a `requires_approval` transition parked awaiting a human
            decision. Non-null while an ApprovalItem gates the move; the task
            stays in its current state and no other transition may fire until the
            approval resolves.
        entered_state_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TaskTransition:
      type: object
      properties:
        id:
          type: string
        task_id:
          type: string
        from_state:
          type: string
          nullable: true
        to_state:
          type: string
        transition:
          type: string
          nullable: true
        principal_type:
          allOf:
            - $ref: './principals.yaml#/components/schemas/PrincipalType'
          description: >
            Who made the move: `user` or `api_key` for an authenticated caller,
            `automation` for the engine acting on an `on_enter` dispatch
            outcome, `approval` for an approval resolution. Named
            `principal_*`, not `actor_*`: these ids never reference the Actors
            module.
        principal_id:
          type: string
          nullable: true
          description: >
            Public id of the principal that made the move — the user
            (`user_...`), or for `api_key` auth the key's own id (`key_...`),
            distinguishing which key acted. Null for `automation`, which has no
            principal: the cause is carried by `generation_id` /
            `orchestration_run_id` / `tool_id`, one per dispatch kind — exactly
            one of which is set on an automation move.
        generation_id:
          type: string
          nullable: true
          description: Set when an `agent` dispatch's generation caused the move.
        orchestration_run_id:
          type: string
          nullable: true
          description: Set when an `orchestration` dispatch's run caused the move.
        tool_id:
          type: string
          nullable: true
          description: >
            Set when a `tool` dispatch caused the move. A tool call produces no
            addressable record of its own, so the tool it called is what records
            why the task moved.
        note:
          type: string
          nullable: true
        from_workflow_version:
          type: integer
          nullable: true
          description: >-
            Set on a migration row only: the version the task left, or null for
            a task that had no pinned version.
        to_workflow_version:
          type: integer
          nullable: true
          description: Set on a migration row only, the version the task moved onto.
        created_at:
          type: string
          format: date-time
    TaskTransitionOption:
      type: object
      required:
        - name
        - to
        - requires_approval
        - allowed
        - error_code
      properties:
        name:
          type: string
        to:
          type: string
          description: The state the transition moves the task to.
        requires_approval:
          type: boolean
          description: Firing it parks an approval instead of moving the task.
        allowed:
          type: boolean
          description: Whether firing it now as the caller would be accepted.
        error_code:
          type: string
          nullable: true
          enum:
            - TASK_TRANSITION_CONFLICT
            - TASK_GUARD_REJECTED
            - null
          description: >-
            The code firing it would answer: `TASK_TRANSITION_CONFLICT` when
            the task is closed, has a transition pending approval, or is not in
            one of the transition's `from` states; `TASK_GUARD_REJECTED` when
            its guard fails. Null when `allowed`.
    CreateTaskRequest:
      type: object
      required:
        - workflow_id
        - title
      additionalProperties: false
      properties:
        project_id:
          type: string
        workflow_id:
          type: string
        title:
          type: string
        payload:
          type: object
        assignee:
          type: string
          nullable: true
        idempotency_key:
          type: string
          maxLength: 255
          description: >-
            The create's deduplication key, unique within the project and
            claimed for as long as the task it created exists. The same key
            with the same body returns that task as it is now (`200`) instead
            of creating a second one; with a different body it is
            `409 IDEMPOTENCY_KEY_REUSED`. A create that fails claims no key.
            Equivalent to the `Idempotency-Key` header.
        state:
          type: string
          description: >-
            Name of a declared workflow state to create the task in directly,
            instead of the workflow's `initial` state. Must name a state
            declared on the workflow, or the request is rejected with
            `TASK_STATE_NOT_FOUND` (400). Defaults to the `initial` state.
        tool_context:
          type: object
          additionalProperties:
            type: string
          description: >-
            Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
            `http`, `mcp` and `builtin` tool call made by this task's automation
            dispatches — the agent generations a state's `on_enter` starts, and
            the agent nodes of any orchestration run it starts. The header name
            is `X-Soat-Context-` plus the key verbatim; no character is re-cased.

            Creation is the task's first move, so this is the bag the entry
            state's `on_enter` runs with. Each transition may replace it (see
            `TransitionTaskRequest.tool_context`).

            The reserved identity keys (`session_id`, `actor_id`,
            `actor_external_id`) are stripped in any casing and re-derived
            server-side, so a task-dispatched generation cannot forge them. A key
            outside the HTTP header-name grammar is rejected with
            `INVALID_TOOL_CONTEXT_KEY` (400).

            Write-only: the stored bag is never returned by any task read, and it
            is cleared when the task reaches a terminal state.
        metadata:
          description: >-
            Caller-supplied key/value metadata attached to the task record for
            attribution — which of your own tenants the task belongs to, the
            ticket that raised it, the import batch that created it.
            Round-trips verbatim on every read of the task, the list included,
            and survives every transition (a transition supplies no metadata of
            its own).


            The bag is caller-owned and no key is reserved: everything the
            engine decides about a task (`state`, `status`, `workflow_version`,
            `last_result`, `active_dispatch`, the automation fields) is a field
            of its own and cannot be written from here.


            Prefer this over `payload` for anything that is not task data:
            `payload` is read by every guard as `task.payload` and may be
            written by the workflow's declared `payload_writes`, so a label
            parked there is neither invisible to the state machine nor safe from
            it. A non-object is rejected with `400 VALIDATION_FAILED` and no
            task is created.
          example:
            tenant_account_id: '42'
            source: zendesk
          allOf:
            - $ref: './metadata.yaml#/components/schemas/MetadataBag'
    UpdateTaskRequest:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
        payload:
          type: object
          description: >-
            Partial payload, shallow-merged over the existing payload. Omitted
            keys are preserved; provided keys overwrite. The merged result must
            satisfy the workflow's payload_schema.
        assignee:
          type: string
          nullable: true
    PauseTaskRequest:
      type: object
      properties:
        reason:
          type: string
          maxLength: 256
          description: Why the task is being paused, surfaced on `pause_reason`.
      additionalProperties: false

    MigrateTaskRequest:
      type: object
      required:
        - to_version
      properties:
        to_version:
          type: integer
          minimum: 1
          description: The workflow version to move the task onto; newer than its current one.
        state_map:
          type: object
          additionalProperties:
            type: string
          description: >-
            Current state name to a state the target version declares. Required
            only for a state the target lacks; an entry for a state both declare
            overrides the identity mapping.
      additionalProperties: false

    TransitionTaskRequest:
      type: object
      required:
        - transition
      additionalProperties: false
      properties:
        transition:
          type: string
        note:
          type: string
          nullable: true
        payload:
          type: object
          description: >-
            Keys shallow-merged over the task's payload under the transition's
            lock, before the guard reads it, exactly as `PATCH` merges them; the
            result must satisfy the payload schema of the version the task is
            pinned to (`TASK_PAYLOAD_INVALID`). A refused move writes none of
            it. On a `requires_approval` transition the payload is held with the
            gate and merged when the approval fires the move, so a rejected or
            expired gate writes nothing.
        idempotency_key:
          type: string
          maxLength: 255
          description: >-
            The move's deduplication key, unique within the task and claimed
            for as long as the task exists. The same key with the same body
            replays the task as it is now instead of moving it again, whether
            the move applied or parked an approval; with a different body it is
            `409 IDEMPOTENCY_KEY_REUSED`. Equivalent to the `Idempotency-Key`
            header.
        tool_context:
          type: object
          additionalProperties:
            type: string
          description: >-
            Caller context for the automation dispatches the task makes from
            here on, forwarded as `X-Soat-Context-<key>` headers on their tool
            calls.

            Supplying it **replaces** the task's stored bag wholesale; omitting
            it keeps the current one, so the context follows whoever last moved
            the task and survives every move that does not speak about it —
            including an approval gate, a retry, and an automation hop. Send an
            empty object to clear it without closing the task.

            The reserved identity keys (`session_id`, `actor_id`,
            `actor_external_id`) are stripped in any casing and re-derived
            server-side. A key outside the HTTP header-name grammar is rejected
            with `INVALID_TOOL_CONTEXT_KEY` (400).

            Write-only: never returned by a task read, and cleared when the
            transition closes the task.
