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, or
        assignee — `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
          schema:
            type: string
            enum:
              - open
              - closed
        - name: assignee
          in: query
          required: false
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Maximum number of results to return
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          required: false
          description: Number of results to skip
          schema:
            type: integer
            default: 0
      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
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTaskRequest'
      responses:
        '201':
          description: Task created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          description: >-
            Bad request — invalid payload (`TASK_PAYLOAD_INVALID`), or `state`
            does not name a declared state of the workflow
            (`TASK_STATE_NOT_FOUND`)
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Workflow not found

  /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:
    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
      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, or the task is closed

  /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
      responses:
        '200':
          description: The task's transition history, oldest first
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TaskTransition'
        '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:
          type: object
          additionalProperties: true
          nullable: true
          description: >-
            The caller-owned key/value metadata supplied when the task was
            created, returned verbatim. Null 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
        last_result:
          nullable: true
          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.
        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
            - null
          description: >-
            Status of the current state's dispatch. `null` until a state with an
            automation is entered.
        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_kind:
          type: string
          description: >
            Who made the move. `user` and `api_key` are authenticated
            principals; `automation` (the engine acting on an `on_enter`
            dispatch outcome) and `approval` (an approval resolution) are system
            principals. Named `principal_*`, not `actor_*`: these ids never
            reference the Actors module.
          enum:
            - user
            - api_key
            - automation
            - approval
        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
        created_at:
          type: string
          format: date-time
    CreateTaskRequest:
      type: object
      required:
        - workflow_id
        - title
      properties:
        project_id:
          type: string
        workflow_id:
          type: string
        title:
          type: string
        payload:
          type: object
        assignee:
          type: string
          nullable: true
        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:
          type: object
          additionalProperties: true
          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
    UpdateTaskRequest:
      type: object
      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
    TransitionTaskRequest:
      type: object
      required:
        - transition
      properties:
        transition:
          type: string
        note:
          type: string
          nullable: true
        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.
