# Generated from openapi/v1.yaml by scripts/site/build-public-openapi.mjs. Do not edit this copy.
# It differs from the source only where that script filters it: maintainer comments are removed;
# internal issue, migration, database and source-file references are removed from descriptions;
# a credential default the server rejects is removed; and the server URL is made absolute
# (https://api.hivemindax.com/api/v1) because this copy is served from hivemindax.com, not from the API host.
openapi: 3.0.3
info:
  title: Hypereum Hivemind API
  description: |
    REST API for the Hypereum Hivemind autonomous agent orchestration platform.

    > **OpenAPI coverage note**: This spec documents the core routes (authentication,
    > missions, mission schedules, artifacts, audit, search, webhooks, bulk operations, notifications,
    > integrations, workspace admin). Complete coverage of all routes is in progress;
    > additional endpoints exist that are not yet described here.

    ## Authentication
    All endpoints require authentication via one of two schemes: the `hm_session`
    cookie (issued at login; used by the first-party web console) or an opaque
    `hmt_...` API token sent as `Authorization: Bearer hmt_...` (used by SDKs,
    the CLI, and MCP-over-HTTP integrations). See `SessionCookieAuth` and
    `ApiTokenAuth` under Security Schemes below.
    Additionally, workspace-scoped endpoints require workspace resolution via the path parameter.

    ## Error Handling
    All errors follow a consistent ErrorResponse format with stable error codes.

    ## Rate Limiting
    Requests are rate-limited per workspace. See quota endpoints for usage details.

    ## WebSocket Real-time Events
    Connect to `/api/ws` for real-time mission updates and events.

    ### Connection
    - **Endpoint**: `wss://api.hivemindax.com/api/ws`
    - **Protocol**: WebSocket (RFC 6455)
    - **Authentication**: Browser clients ride the `hm_session` cookie automatically; non-browser clients pass an API token via `Authorization: Bearer hmt_...` during handshake

    ### Message Format
    All messages are JSON with the following structure:
    ```json
    {
      "type": "event_type",
      "timestamp": "2024-01-15T10:30:00Z",
      "payload": { ... }
    }
    ```

    ### Client → Server Messages
    | Type | Description |
    |------|-------------|
    | `subscribe` | Subscribe to workspace events |
    | `unsubscribe` | Unsubscribe from workspace events |
    | `ping` | Keep-alive ping |

    ### Server → Client Events
    | Event | Description |
    |-------|-------------|
    | `mission.created` | New mission created |
    | `mission.started` | Mission execution started |
    | `mission.completed` | Mission finished successfully |
    | `mission.failed` | Mission failed |
    | `mission.cancelled` | Mission cancelled |
    | `stage.started` | Mission stage started |
    | `stage.completed` | Mission stage completed |
    | `stage.failed` | Mission stage failed |
    | `artifact.created` | New artifact generated |
    | `artifact.verified` | Artifact integrity verified |
    | `audit.event` | Audit event logged |

    ### Reconnection
    On disconnect, clients should:
    1. Wait 1-5 seconds (exponential backoff)
    2. Reconnect with the same credential (session cookie or API token)
    3. Re-subscribe to desired workspaces
    4. Request missed events via REST API if needed
  version: 1.0.0
  contact:
    name: Hypereum
    url: https://www.hypereum.tech
  license:
    name: Proprietary
    url: https://www.hypereum.tech/license

servers:
  - url: https://api.hivemindax.com/api/v1
    description: API v1 base path

security:
  - SessionCookieAuth: []
  - ApiTokenAuth: []

tags:
  - name: Authentication
    description: Authentication and authorization endpoints
  - name: Missions
    description: Mission lifecycle management
  - name: Mission Attachments
    description: >-
      Operator-supplied files bound to ONE mission and read by that mission's
      agents at execution time. Text formats only; content is sanitised, never
      trusted.
  - name: Artifacts
    description: Append-only artifact storage and retrieval with hash-chain integrity
  - name: Search
    description: Advanced search across all workspace entities
  - name: Bulk Operations
    description: Batch processing for missions, artifacts, and notifications
  - name: Notifications
    description: Multi-channel notification system with templates and channels
  - name: Webhooks
    description: Webhook subscription management and delivery
  - name: Audit
    description: Append-only audit log with hash-chain export and event integrity verification
  - name: Integrations
    description: Third-party integration management (GitHub, etc.)
  - name: Workspace Admin
    description: Workspace administration and quota management
  - name: Analytics
    description: Workspace analytics — mission and token-usage aggregates with real per-model pricing
  - name: Model Routing
    description: >-
      Per-workspace LLM routing surface: the runtime provider/model inventory
      with health and workspace controls.
  - name: Oversight
    description: >-
      Human-oversight approval surface: the read queue (pending/decided
      approval requests) AND the approve/reject decision mutations. The
      decision endpoints are session-only — an API token receives 403
      regardless of scope, because oversight decisions require an
      interactive MFA session.
  - name: Schedules
    description: >-
      Cron mission schedules — the management surface behind Settings →
      Schedules: list, create, partial update, delete, plus a dry cron
      validator. Dispatch runs in the scheduler service; every mission it
      starts passes the launch approval gate at origin `schedule`.

paths:
  /auth/me:
    get:
      operationId: getCurrentUser
      tags: [Authentication]
      summary: Get current user
      description: Returns the currently authenticated user's profile
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Current user profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/missions:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listMissions
      tags: [Missions]
      summary: List missions
      description: List missions in the workspace with optional filtering
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 500
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
        - name: state
          in: query
          schema:
            $ref: '#/components/schemas/MissionState'
      responses:
        '200':
          description: List of missions
          content:
            application/json:
              schema:
                type: object
                properties:
                  missions:
                    type: array
                    items:
                      $ref: '#/components/schemas/Mission'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createMission
      tags: [Missions]
      summary: Create mission
      description: Create a new mission with the specified template and parameters
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMissionRequest'
      responses:
        '201':
          description: Mission created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MissionCreateResponse'
        '200':
          description: >-
            A mission of this workspace already carries this `idempotency_key`; it is returned with
            `created: false` and nothing new is created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MissionCreateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/QuotaExceeded'
        '403':
          $ref: '#/components/responses/PolicyViolation'

  /workspaces/{workspaceSlug}/missions/plan-preview:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: previewMissionPlan
      tags: [Missions]
      summary: Preview the plan for an objective
      description: |
        Read-only "what would happen if I launched this" for a plain-language
        objective. Runs the real intake pipeline — the same classifier
        `/missions/from-objective` uses, the same planner call
        `/planner/generate` makes, the model selection the mission itself
        runs for each planned task (the policy engine under
        `AX_ROUTING_POLICY=enforce`, otherwise the task executor's automatic
        choice), and the same plan-approval-hold resolver and predicate the
        planner worker uses — and creates NOTHING: no mission, stage, task or
        agent_run row.

        Degradation is labelled, never hidden. When the planner dry-run fails,
        `plan` is null, `plan_error` carries the reason and `plan_source`
        becomes `template_prior`, meaning the proposed roles came from the
        classified template rather than from a plan. Only a real dry-run
        yields `planner_dry_run`.

        The plan is illustrative (`plan_binding: illustrative`): the mission's
        own planner run at launch is authoritative for the task graph. Per-role
        model choices are the part that binds exactly — submit them as
        `approved_model_plan` on `/missions/from-objective`. Only servable
        models are listed per role; a role that resolves to none is a dropped
        role with the reason. `estimated_cost_usd` is the median settled cost
        of this workspace's completed missions on the same template (and
        triage tier) over 30 days, rounded to cents, or null with
        `estimate_basis: no_history` — never a per-token guess.

        The preview's own LLM calls are ledgered under `preview_id`. Send it
        back as `preview_id` on `/missions/from-objective` and that spend is
        attributed to the mission the launch creates, before it starts.

        `governance` states, for THIS objective, whether the plan will hold
        for a human plan approval before it runs. A workspace that stores no
        plan-hold setting is armed by default for high- and critical-risk
        missions (`plan_hold.source: platform_default`); a stored setting
        (PUT /workspaces/{slug}/oversight/plan-hold) decides otherwise.
        `governance.egress` is the egress allowlist the mission's
        `http_request` calls are classified against, read from the same
        resolver the tool loop uses: `empty` means every external fetch halts
        for human approval.

        Operator role or above. Rate-limited to 60 previews per minute per
        workspace and user (this endpoint makes a real LLM call); the 429
        carries a `Retry-After` header. Audited as `mission.plan_previewed`
        with a 16-hex digest of the objective — never the objective text.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [objective]
              properties:
                objective:
                  type: string
                  minLength: 10
                  maxLength: 57344
                  description: The plain-language objective to classify and plan for.
                data_workspace_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: >-
                    The corpus to draft the plan with — the same three values
                    /missions/from-objective accepts. An id is validated against
                    the caller's own workspace (an unknown or foreign id is a 404)
                    and the plan is drafted with it; null is "none": the plan is
                    drafted with no documents, as a mission launched with null
                    reads none; absent drafts with no documents and reports
                    `corpus.selection: unspecified`. The response's `corpus`
                    block says which.
      responses:
        '200':
          description: The proposed plan, routing, governance posture and model catalog.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanPreviewResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /workspaces/{workspaceSlug}/missions/from-objective:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: createMissionFromObjective
      tags: [Missions]
      summary: Create a mission from a plain-language objective
      description: |
        Classifies the objective and runs the same create pipeline
        `POST /missions` uses. The response carries the classification, the
        optional LLM-extracted intent, and an advisory routing proposal that
        does NOT change how the mission routes.

        `approved_model_plan` is the one field that DOES bind routing: a valid
        plan is written into the mission's inputs as `approvedModelPlan`, which
        pins each named role's model at dispatch. It is validated server-side
        before anything is created — every model id must exist in
        the model catalog, and each role must name at least one SERVABLE model
        that meets that role's capability floor and is not disabled by this
        workspace's model or provider controls. Non-servable ids may appear as
        later preference: they are recorded and skipped at dispatch. A plan that
        fails any of those checks is a 422 and no mission is created. Roles not
        named in the plan route automatically, exactly as without it.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [objective]
              properties:
                objective:
                  type: string
                  minLength: 10
                  maxLength: 57344
                data_workspace_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: >-
                    The corpus the mission's agents read from. An id: that data
                    workspace and no other (a foreign or unknown id is a 404).
                    null: none — the mission reads no document at all, recorded
                    on the mission as `inputs.data_workspace_id: null`. Absent:
                    kept for compatibility — when the mission runs it reads the
                    one data workspace that holds documents if exactly one does,
                    and none when several do. Every created mission chains its
                    choice as `mission.corpus_selection`.
                approved_model_plan:
                  $ref: '#/components/schemas/ApprovedModelPlanRequest'
                preview_id:
                  type: string
                  format: uuid
                  description: >-
                    The `preview_id` a plan preview returned. The preview's own
                    LLM calls, ledgered under it with no mission, are attributed
                    to the mission this request creates before that mission
                    starts.
      responses:
        '201':
          description: Mission created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mission:
                    $ref: '#/components/schemas/Mission'
                  classification:
                    type: object
                    description: >-
                      Same builder, same shape as PlanPreviewResponse.classification.
                    properties:
                      template:
                        type: string
                      confidence:
                        type: number
                        nullable: true
                        description: >-
                          NULL whenever the keyword classifier did not choose `template`.
                      source:
                        type: string
                        enum: [llm_intent, keyword]
                      signal:
                        type: string
                        enum: [matched, no_signal, not_measured]
                      matched_keywords:
                        type: integer
                        nullable: true
                      matched_patterns:
                        type: integer
                        nullable: true
                      matched_semantic_indicators:
                        type: integer
                        nullable: true
                      ambiguity_score:
                        type: number
                        nullable: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/QuotaExceeded'
        '403':
          $ref: '#/components/responses/PolicyViolation'
        '422':
          description: >-
            The submitted `approved_model_plan` is invalid. `code` names the
            reason (`unknown_model`, `no_servable_entry`, `capability_floor_unmet`,
            `model_disabled_by_workspace_control`,
            `provider_disabled_by_workspace_control`, `unknown_role`,
            `duplicate_role`) and `role` / `model_id` name the offending entry.
            No mission is created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvalidModelPlanResponse'

  /workspaces/{workspaceSlug}/gateway/models:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listGatewayModels
      tags: [Model Routing]
      summary: List runtime providers and models
      description: |
        The live adapter inventory: per provider the transport, whether it is
        configured, this workspace's provider control, health, and compliance
        facts; per model the limits, real catalog costs, capability score and
        this workspace's model control.

        `health.circuit` reports the provider circuit-breaker state from the
        gateway's health tracker (`closed`, `open`, `half-open`). It is
        additive — every previously present health field is unchanged — and it
        distinguishes "the breaker tripped and routing is skipping this
        provider right now" from "this provider is not configured", which
        `available` alone does not.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Runtime provider and model inventory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GatewayModelsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/missions/{missionId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    get:
      operationId: getMission
      tags: [Missions]
      summary: Get mission
      description: Get detailed information about a specific mission including stages
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Mission details
          content:
            application/json:
              schema:
                type: object
                properties:
                  mission:
                    $ref: '#/components/schemas/Mission'
                  stages:
                    type: array
                    items:
                      $ref: '#/components/schemas/Stage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/start:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: startMission
      tags: [Missions]
      summary: Start mission execution
      description: |
        Explicitly starts a mission that is still in state `created`
        (`POST /:missionId/start`). Requires the
        `operator` role or higher and an active workspace: a suspended workspace
        answers 403 `WorkspaceSuspended`.

        Missions created through `POST /missions` and `POST /missions/from-objective`
        auto-start at creation, so the common outcome of calling this route is the
        200 already-running branch. It is the primary start path only when the
        launch approval gate held the auto-start.

        Outcome by mission state, evaluated in this order:
        - `completed`, `completed_with_errors`, `failed` or `cancelled`: 400 `InvalidState`.
        - any other state except `created`: 200, no side effects.
        - `created`: the launch approval gate is consulted (`LAUNCH_GATE_ENABLED`,
          default off). Gate off: the mission is started and the reply is the 202
          `ExecutionStarted` variant. Gate on without an approved launch approval:
          the 202 `LaunchApprovalRequired` variant — the mission stays `created`
          and is NOT started; approve the referenced request through
          `POST /missions/{missionId}/approvals/{approvalId}/approve` (two distinct
          approvers when `dualApproverRequired` is true), then call this route
          again. A rejected launch approval keeps answering this variant.
        - An engaged workspace emergency stop refuses the start: 409 `EmergencyStopActive`.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '202':
          description: |
            Two variants, discriminated by `approvalRequired`: `ExecutionStarted`
            (the mission was started; `approvalRequired` is absent) or
            `LaunchApprovalRequired` (`approvalRequired: true`; the mission was
            not started and remains `created`).
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: ExecutionStarted
                    required: [message, missionId, executionId, state]
                    properties:
                      message:
                        type: string
                        enum: ['Mission execution started']
                      missionId:
                        type: string
                        format: uuid
                      executionId:
                        type: string
                        format: uuid
                        description: >-
                          A fresh random UUID generated for this response. It is
                          not persisted and cannot be looked up; poll
                          `GET /missions/{missionId}` for progress.
                      state:
                        allOf:
                          - $ref: '#/components/schemas/MissionState'
                        description: >-
                          The mission state re-read after the start was admitted
                          (the pre-start state if the re-read returns no row).
                  - type: object
                    title: LaunchApprovalRequired
                    required: [message, missionId, state, approvalRequired, approvalId, dualApproverRequired]
                    properties:
                      message:
                        type: string
                        enum:
                          - 'Launch approval required — approval request created'
                          - 'Launch approval pending — approve it, then call start again'
                          - 'Launch approval was rejected — mission start remains blocked'
                      missionId:
                        type: string
                        format: uuid
                      state:
                        type: string
                        enum: [created]
                        description: The mission stays `created` while held.
                      approvalRequired:
                        type: boolean
                        enum: [true]
                      approvalId:
                        type: string
                        format: uuid
                        description: >-
                          The launch `approval_requests` row to decide — created by
                          this call, or the existing pending/rejected one.
                      dualApproverRequired:
                        type: boolean
                        description: >-
                          Whether two distinct approvers are required, from the
                          workspace's active `require_human_approval` policy
                          (`launch_dual_approver`).
        '200':
          description: >-
            The mission is neither `created` nor terminal, so it is treated as
            already running; nothing changes.
          content:
            application/json:
              schema:
                type: object
                required: [message, missionId, state, mission]
                properties:
                  message:
                    type: string
                    description: '`Mission already running (state: <state>)`'
                  missionId:
                    type: string
                    format: uuid
                  state:
                    $ref: '#/components/schemas/MissionState'
                  mission:
                    $ref: '#/components/schemas/Mission'
        '400':
          description: >-
            `InvalidState` — the mission is in a terminal state (`completed`,
            `completed_with_errors`, `failed`, `cancelled`); `BadRequest` —
            `missionId` is not a UUID.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [InvalidState, BadRequest]
                  message:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            `Forbidden` — the caller is not a workspace member or is below
            `operator`; `WorkspaceSuspended` — the workspace is suspended.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [Forbidden, WorkspaceSuspended]
                  message:
                    type: string
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `EmergencyStopActive` — the workspace emergency stop is engaged; the
            mission is unchanged.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [EmergencyStopActive]
                  message:
                    type: string
                    enum: ['workspace emergency stop active']

  /workspaces/{workspaceSlug}/missions/{missionId}/cancel:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: cancelMission
      tags: [Missions]
      summary: Cancel mission
      description: Cancel a running or queued mission
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Mission cancelled successfully
          content:
            application/json:
              schema:
                type: object
                required: [mission]
                properties:
                  mission:
                    $ref: '#/components/schemas/Mission'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/pause:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: pauseMission
      tags: [Missions]
      summary: Pause mission
      description: |
        Pauses an `executing` or `planning` mission (EU AI Act Article 14
        human oversight; operator role or above). Writes a
        `human_oversight_events` row (`mission_paused`). Any other mission
        state returns 400 `InvalidState`.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional operator rationale, stored on the oversight event.
      responses:
        '200':
          description: Mission paused
          content:
            application/json:
              schema:
                type: object
                properties:
                  mission:
                    $ref: '#/components/schemas/Mission'
        '400':
          description: InvalidState — the mission is not `executing` or `planning`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/resume:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: resumeMission
      tags: [Missions]
      summary: Resume mission
      description: |
        Resumes a `paused` mission back to `executing` (EU AI Act Article 14
        human oversight; operator role or above). Writes a
        `human_oversight_events` row (`mission_resumed`). Any other mission
        state returns 400 `InvalidState`.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional operator rationale, stored on the oversight event.
      responses:
        '200':
          description: Mission resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  mission:
                    $ref: '#/components/schemas/Mission'
        '400':
          description: InvalidState — the mission is not `paused`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/tasks/{taskId}/override:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/TaskId'
    post:
      operationId: overrideTaskOutput
      tags: [Missions]
      summary: Override task output
      description: |
        Replaces a task's output with a human-supplied value (EU AI Act
        Article 14 human oversight; operator role or above AND an
        interactive session). Records the original and new value on a
        `human_oversight_events` row (`agent_output_overridden`).

        API tokens receive 403 regardless of scope — this is deliberate
        (oversight decisions require an interactive MFA session, so this
        endpoint carries SessionCookieAuth only).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [newOutput, reason]
              properties:
                newOutput:
                  type: string
                  description: Replacement output for the task
                reason:
                  type: string
                  description: Operator rationale (required)
      responses:
        '200':
          description: Task output overridden
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  taskId:
                    type: string
                    format: uuid
        '400':
          description: newOutput and reason are required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/evidence:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    get:
      operationId: getMissionEvidence
      tags: [Missions]
      summary: Get mission evidence
      description: Retrieve evidence bundle for mission verification (append-only, exportable hash chain)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Mission evidence
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MissionEvidence'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/evidence/verify:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: verifyMissionEvidence
      tags: [Missions]
      summary: Verify mission evidence
      description: Verify the cryptographic integrity of mission evidence
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/mission-files/{missionId}/attachments:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    get:
      operationId: listMissionAttachments
      tags: [Mission Attachments]
      summary: List a mission's operator attachments
      description: >-
        The files an operator attached for THIS mission's agents to read.
        Metadata only — the file's text is never returned here.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: The mission's attachments, and the limits that apply to them
          content:
            application/json:
              schema:
                type: object
                properties:
                  attachments:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        missionId:
                          type: string
                          format: uuid
                        filename:
                          type: string
                        mediaType:
                          type: string
                        sizeBytes:
                          type: integer
                          description: Bytes of the original upload.
                        textChars:
                          type: integer
                          description: Characters of the text the mission actually reads.
                        sha256:
                          type: string
                          description: SHA-256 of the uploaded bytes.
                        sanitized:
                          type: boolean
                          description: >-
                            True when the prompt-injection/PII/secret sanitiser
                            changed the text before it was stored.
                        uploadedBy:
                          type: string
                          format: uuid
                        createdAt:
                          type: string
                          format: date-time
                  maxAttachments:
                    type: integer
                  maxBytes:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      operationId: attachFileToMission
      tags: [Mission Attachments]
      summary: Attach a file to a mission
      description: >-
        Accepts a UTF-8 TEXT file as base64 and binds it to this mission — and only
        this mission — as context its agents read at execution time. The bytes are
        validated before anything is stored (magic-byte signature sniff, strict
        UTF-8 round trip, filename rules covering path separators, control
        characters, bidi/invisible characters, Windows device names and length),
        and the text is passed through the prompt-injection sanitiser. That
        sanitiser is a PATTERN BLOCKLIST; it is not a solution to prompt injection
        and this endpoint does not claim to be one. No virus scanning exists on
        this path.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, contentBase64]
              properties:
                filename:
                  type: string
                  maxLength: 255
                contentBase64:
                  type: string
                  description: The file's bytes, base64-encoded.
                declaredMime:
                  type: string
                  maxLength: 255
                  description: >-
                    The browser's File.type. RECORDED, never acted on. It cannot widen
                    what is accepted — the extension allowlist, the magic-byte sniff and
                    the strict-UTF-8 decode are the whole gate and none of them reads it —
                    and it is no longer a rejection when it disagrees with the extension,
                    because File.type comes from the OS media-type registry rather than
                    from the file, so ordinary .csv, .xml, .yaml and .md uploads were being
                    refused for values the operator could not change.
      responses:
        '201':
          description: Attached to the mission
          content:
            application/json:
              schema:
                type: object
                properties:
                  attachment:
                    type: object
                  deduplicated:
                    type: boolean
                  sanitizerRemoved:
                    type: array
                    items:
                      type: string
        '200':
          description: This exact text was already attached to this mission
          content:
            application/json:
              schema:
                type: object
                properties:
                  attachment:
                    type: object
                  deduplicated:
                    type: boolean
                  sanitizerRemoved:
                    type: array
                    items:
                      type: string
        '400':
          description: >-
            The filename was refused — path separator, control character,
            bidi/invisible character, reserved device name, or no extension.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            This exact text is already on the mission as a different kind of context
            (a message, say), so it cannot also exist as an attachment — the ingest
            layer dedups on content hash across every source type, and the id it
            returns for such a row is one the attachment list and delete routes
            cannot act on. The mission can already read the text.
        '413':
          description: >-
            Over a size, count or storage cap — the file itself, the mission's
            prompt-text budget, the mission's file count, or the workspace's total
            attachment storage.
        '415':
          description: >-
            Not an accepted text format, or the bytes are not text (a binary
            signature was detected, or the file is not valid UTF-8).

  /workspaces/{workspaceSlug}/mission-files/{missionId}/attachments/{attachmentId}/content:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - name: attachmentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getMissionAttachmentContent
      tags: [Mission Attachments]
      summary: Download exactly the text this mission reads
      description: >-
        Returns the SANITISED text — the same bytes the mission's agents receive,
        not the original upload, which is not retained. Always served as
        application/octet-stream with a quote-free filename, so a browser cannot be
        talked into rendering it on this origin.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: The attachment's text
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/mission-files/{missionId}/attachments/{attachmentId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - name: attachmentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      operationId: removeMissionAttachment
      tags: [Mission Attachments]
      summary: Take a file away from a mission
      description: >-
        Removes the attachment so later task dispatches no longer read it. Tasks
        that already ran keep whatever they were given; this is a forward-looking
        removal, not a retraction.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '204':
          description: Removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/artifacts:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    get:
      operationId: listArtifacts
      tags: [Artifacts]
      summary: List artifacts
      description: List all artifacts associated with a mission
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: List of artifacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  artifacts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Artifact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/artifacts/{artifactId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ArtifactId'
    get:
      operationId: getArtifact
      tags: [Artifacts]
      summary: Get artifact
      description: Get artifact metadata
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Artifact details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Artifact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/artifacts/{artifactId}/content:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ArtifactId'
    get:
      operationId: getArtifactContent
      tags: [Artifacts]
      summary: Get artifact content
      description: Download artifact content (may redirect to signed URL)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Artifact content
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '302':
          description: Redirect to signed URL
          headers:
            Location:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/artifacts/{artifactId}/url:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ArtifactId'
    get:
      operationId: getArtifactSignedUrl
      tags: [Artifacts]
      summary: Get artifact signed URL
      description: Get a temporary signed URL for direct artifact access
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Signed URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                  expiresAt:
                    type: string
                    format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/artifacts/{artifactId}/verify:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ArtifactId'
    get:
      operationId: verifyArtifact
      tags: [Artifacts]
      summary: Verify artifact integrity
      description: Verify the cryptographic signature of an artifact
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/analytics:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getWorkspaceAnalytics
      tags: [Analytics]
      summary: Workspace analytics
      description: |
        Returns mission and token-usage analytics for the workspace.
        The `total_cost_usd` field reflects **real cost** aggregated by
        `trackTokens()` using per-model pricing at write time (stored in
        `workspace_usage.total_llm_cost_usd`). A value of 0 means no LLM
        calls were recorded for the requested period — not a rough estimate.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: period
          in: query
          schema:
            type: string
            enum: [7d, 30d, 90d]
            default: 30d
          description: Time window for analytics aggregation
      responses:
        '200':
          description: Workspace analytics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkspaceAnalytics'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/usage/ledger/breakdown:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getWorkspaceUsageLedgerBreakdown
      tags: [Analytics]
      summary: Workspace LLM usage breakdown
      description: |
        Everything the operator console's Usage page renders, in one workspace-scoped read:
        window totals, per-model and per-role breakdowns, the top-10 missions with a
        previous-window delta, the mission -> role -> model Sankey flow tables, a per-day
        series and an hour-of-week heatmap.

        **Source.** Aggregated from `llm_call_ledger` (ADR-0001) and nothing else. That table
        is written once per provider attempt from the single LLM-gateway chokepoint, so it
        covers every caller and every failed attempt by construction, its prompt/completion
        split is the provider's own, and its cost is null-honest. `agent_runs` is deliberately
        NOT mixed in: its `cost_usd` is a flat-rate constant, and it overlaps the ledger for the
        mission executor's own per-task loop, so a union would both fabricate money and
        double-count.

        **Cost honesty.** `costUsd` sums ONLY rows whose `cost_usd IS NOT NULL`. NULL means
        "pricing could not be resolved", never zero, and is never coalesced to 0; the count of
        such calls is reported alongside as `costUnknownCalls` (and `costKnownCalls` in
        `totals`) so a client can disclose partial pricing coverage. A `costUsd` of 0 together
        with `costKnownCalls: 0` means "no priced call in this window", not "$0.00 spent", and
        should render as an em dash rather than a money value.

        **Unattributed spend.** `mission_id` and `agent_role` are nullable in the ledger
        (pre-mission spend; non-task-scoped gateway callers). Those rows are never dropped and
        never given an invented value: a NULL role folds into the role `"unattributed"`, a NULL
        mission into the `missionKey` `"unattributed"` (which is deliberately absent from
        `missions[]`, because it is not a mission). Their tokens and calls remain in `totals`,
        and `totals.unattributedTokens` / `unattributedCalls` disclose the amount.

        **Conservation invariant.** `models`, `roles`, per-mission tokens and both flow tables
        are folded from a single `GROUP BY mission_id, agent_role, model, provider` aggregate,
        so the marginals balance exactly: `flows.missionRole` row-sums per `missionKey` equal
        that mission's tokens and its col-sums per `role` equal `roles[].tokens`;
        `flows.roleModel` row-sums per `role` equal `roles[].tokens` and its col-sums per
        `model` equal `models[].tokens`. `flows.missionRole` therefore carries EVERY mission
        key observed in the window, not only the ten returned in `missions[]` — a client that
        wants a bounded Sankey folds the tail into its own "other" node rather than receiving a
        response with tokens silently missing.

        All token fields are RAW token counts, not thousands. RBAC: `viewer` (workspace
        operational data, matching `/analytics`).
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: ['24h', '7d', '30d']
            default: '7d'
          description: |
            Aggregation window, ending at request time. `24h` = 1 day, `7d` = 7 days,
            `30d` = 30 days. Any other value is rejected with 400 rather than silently
            answering a different question.
      responses:
        '200':
          description: Usage breakdown for the requested window
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageBreakdown'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/usage/ledger/residency:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getWorkspaceUsageLedgerResidency
      tags: [Analytics]
      summary: Which regions served this workspace's LLM calls
      description: |
        Per-call data-residency disclosure, aggregated from `llm_call_ledger.dispatch_region` /
        `region_source`. Answers "which AWS regions actually served this
        workspace's LLM calls in the window, and how do we know".

        **What `region` is.** The region each call was DISPATCHED to, recorded from the AWS SDK
        client that performed that invocation — never from an environment variable read at
        write time, which would record configured intent rather than observed fact.
        `wireEndpointCalls` counts calls whose region came from the endpoint hostname the SDK
        actually contacted; `clientConfigCalls` counts the weaker fallback, the invoking
        client's own resolved region. The split is reported rather than averaged so a reader
        can weigh the evidence.

        **What `region` is NOT.** It is not proof of the region that executed the inference.
        Anthropic on Bedrock in the EU is served through an `eu.`-scoped cross-region inference
        profile, and the Bedrock Converse response discloses no serving region, so the serving
        region is not observable at the gateway. The bound on the serving geography comes from
        the profile and the EU-residency region allowlist, not from this field.

        **`notObservedCalls`.** Calls in the window with no observed region — a direct-API
        transport has no AWS region, and an attempt that failed before dispatch observed none.
        Disclosed as its own number, never folded into a region and never dropped, so a reader
        can always see how much of the window the residency answer does not cover.

        **`euResidency`.** Computed from the explicit EU-member-state allowlist, which excludes
        `eu-west-2` (London, UK) and `eu-central-2` (Zurich, CH) despite their `eu-` prefix. A
        non-EU region is returned with `euResidency: false` rather than filtered out of the
        answer.

        RBAC: `viewer` (workspace operational data, matching `/usage/ledger/breakdown`).
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: ['24h', '7d', '30d']
            default: '7d'
          description: |
            Aggregation window, ending at request time. `24h` = 1 day, `7d` = 7 days,
            `30d` = 30 days. Any other value is rejected with 400.
      responses:
        '200':
          description: Region disclosure for the requested window
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageLedgerResidency'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/approval-requests:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listApprovalRequests
      tags: [Oversight]
      summary: List approval requests
      description: |
        Returns the oversight approval queue: `pending` approval requests
        (oldest / most-overdue first, up to 100) and `recent` decided
        requests (approved/rejected/expired/auto_approved, most recently
        decided first, up to 50). Reads are RLS-native — armed via the
        tenant chokepoint against the FORCE-RLS `approval_requests` table.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Approval request queue
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApprovalQueueResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/approval-requests/{approvalId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/ApprovalId'
    get:
      operationId: getApprovalRequest
      tags: [Oversight]
      summary: Get approval request
      description: |
        Returns full detail for a single approval request, including its
        `context` (approvers list for dual-approval TIER_4 requests) and the
        computed `approvals_so_far` count.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Approval request detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  approval:
                    $ref: '#/components/schemas/ApprovalRequestDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/missions/{missionId}/approvals/{approvalId}/approve:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ApprovalId'
    post:
      operationId: approveApproval
      tags: [Oversight]
      summary: Approve approval request
      description: |
        Approves a pending Tier 3/4 approval request (EU AI Act Article 14
        human oversight). Dual control applies to PROCEED only: a TIER_4
        request (`dual_approver_required: true`) flips to `approved` only
        after two DISTINCT user IDs have approved — the first vote returns
        202 and records the approver in `context.approvers`, the deciding
        vote returns 200 and resumes the mission. A single operator deny is
        always sufficient to stop (see the reject endpoint) — stopping never
        needs a second vote. The decision, the Article-14
        `human_oversight_events` row (`approval_granted`) and the
        hash-chained, append-only `audit_events` entry commit or roll back
        together in one transaction; a repeat approve of a decided row is a
        409, never a double-write.

        API tokens receive 403 regardless of scope — this is deliberate
        (oversight decisions require an interactive MFA session, so this
        endpoint carries SessionCookieAuth only).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  maxLength: 2000
                  description: >-
                    Optional operator rationale, stored as `decision_notes`
                    and in the audit metadata.
                client_dwell_ms:
                  type: integer
                  minimum: 1
                  maximum: 86400000
                  description: |
                    ADVISORY-ONLY oversight telemetry (Art. 14(4)(b)
                    automation-bias countermeasure): how many milliseconds the
                    decision packet was visible in the operator's console
                    before this click. Client-reported and therefore
                    spoofable — it is recorded as disclosure, never used to
                    gate the decision and never an input to any compliance
                    verdict. There is no column for it: it is folded into the
                    approval row's `context` jsonb under `_telemetry`
                    (`{client_dwell_ms, advisory: true, recorded_at}`) in the
                    SAME decision UPDATE. A missing, non-numeric or
                    out-of-range value is IGNORED — it never produces a 400,
                    because bad telemetry must not block a human decision.
                webauthn:
                  allOf:
                    - $ref: '#/components/schemas/DecisionWebauthnAssertion'
                  description: |
                    Optional per-decision WebAuthn assertion over a challenge
                    obtained from the `webauthn-challenge` endpoint. Ignored
                    entirely while the workspace oversight policy's `webauthn`
                    mode is `off` (the fleet-wide default). Under `required`
                    this field is MANDATORY for approve: a missing one is a
                    403 with no decision recorded. An assertion that fails
                    verification — bad signature, wrong credential, consumed
                    or expired challenge, or a binding that no longer matches
                    current state — is a 403 with NO decision recorded, in
                    every mode.
      responses:
        '200':
          description: |
            Approval approved and mission resumed. Single-approver (TIER_3)
            requests return the full updated approval row; the TIER_4
            deciding vote returns the decision summary
            (`{id, status, approvers}`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  approval:
                    oneOf:
                      - $ref: '#/components/schemas/ApprovalRequestDetail'
                      - $ref: '#/components/schemas/ApprovalDecisionSummary'
        '202':
          description: >-
            TIER_4 first vote recorded; the request stays `pending` until a
            second distinct approver approves.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DualApprovalFirstVoteResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Approval already decided, duplicate vote (the same user cannot
            supply both TIER_4 approvals), or malformed approver history in
            `context.approvers`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/missions/{missionId}/approvals/{approvalId}/webauthn-challenge:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ApprovalId'
    post:
      operationId: issueDecisionWebauthnChallenge
      tags: [Oversight]
      summary: Issue a one-time WebAuthn challenge for a decision
      description: |
        Issues a fresh, single-use server challenge so an operator can sign
        THIS decision on THIS approval with an already-registered login
        passkey. There is no registration surface here — passkeys are
        registered through the existing account Security flow; this endpoint
        only starts an assertion ceremony.

        The returned `bindingHash` is `sha256` over the canonical JSON of
        `{approval_id, decision, context_hash, approval_tier, policy_hash}` —
        the approval packet's identity, the intended decision, and the
        content hash of the ACTIVE oversight policy. It is recomputed from
        current state when the assertion is verified, so a change to the
        approval's context, its tier, or the governing policy between
        challenge and signature refuses the assertion.

        Availability is governed by the workspace oversight policy's
        `webauthn` mode. While the mode is `off` — the fleet-wide default —
        this endpoint returns 409 and per-decision assertions are inert.
        Raising the mode is itself a Tier-4 dual-approver `policy_change`.

        SESSION-ONLY: like the decision endpoints it evidences, this route
        requires a proven interactive human session (login + MFA) on an
        operator-or-above membership. API tokens receive 403 regardless of
        scope, so it carries SessionCookieAuth only.
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [approved, rejected]
                  description: >-
                    The decision the operator intends to sign. It is bound
                    into `bindingHash`, so a challenge issued for one
                    decision cannot be replayed on the other.
      responses:
        '200':
          description: Challenge issued; single-use and valid for `expiresInSeconds`.
          content:
            application/json:
              schema:
                type: object
                required: [challengeId, challenge, bindingHash, rpId, allowCredentials, expiresInSeconds]
                properties:
                  challengeId:
                    type: string
                    format: uuid
                    description: >-
                      Server handle for this challenge; echoed back as
                      `webauthn.challengeId` on the decision request. Consumed
                      atomically on first use.
                  challenge:
                    type: string
                    description: Base64url challenge to pass to the authenticator.
                  bindingHash:
                    type: string
                    description: >-
                      Hex sha256 of the canonical decision binding (see the
                      description). Recomputed and compared at verify time.
                  rpId:
                    type: string
                    description: Relying-party id the assertion must be made for.
                  allowCredentials:
                    type: array
                    description: >-
                      The caller's already-registered passkey credential
                      descriptors. No other credential can satisfy the
                      assertion.
                    items:
                      type: object
                  expiresInSeconds:
                    type: integer
                    description: Time-to-live of the challenge.
        '400':
          description: '`decision` missing or not one of `approved` / `rejected`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Per-decision assertions are `off` under this workspace's
            oversight policy, the approval is already decided, or the caller
            has no registered passkey to assert with.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: >-
            The deployment has no WebAuthn verification stack configured
            (challenge store / relying-party id / origin allowlist), so no
            challenge can be issued. Fail-closed: enforcing modes refuse the
            approve rather than admitting it unsigned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/emergency-stop:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: engageEmergencyStop
      tags: [Oversight]
      summary: Engage the workspace emergency stop
      description: |
        Engages the EU AI Act Art. 14(4)(e) stop procedure for the whole
        workspace: `workspaces.stop_active` is set, every planning/executing
        mission is paused with its own Article-14 `human_oversight_events`
        row, and a workspace-level hash-chained audit entry records who
        stopped it and why. While the stop is engaged the stop BEATS EVERY
        GRANT — an already-approved but unclaimed tool call is refused at the
        claim layer (auto-approved records included), and every mission start
        is refused.
        SESSION-ONLY: a stop is an oversight decision, so it requires a proven
        interactive human session (login + MFA) on an operator-or-above
        membership. An API token of any scope is refused 403 before anything
        is written. Single-human by design in both directions — stopping must
        never be harder than proceeding.
        `reason` is REQUIRED (max 2000 characters): an unexplained stop is not
        auditable.
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  minLength: 1
                  maxLength: 2000
                  description: Why the workspace is being stopped. Recorded in the audit chain.
      responses:
        '200':
          description: Stop engaged; reports how many missions were paused by this call.
          content:
            application/json:
              schema:
                type: object
                properties:
                  stopped:
                    type: boolean
                  missions_paused:
                    type: integer
                    description: Missions moved to paused by this engagement.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            A stop is ALREADY active for this workspace (compare-and-set
            refusal — a second engage never re-stamps the first stop's
            reason/actor). The body carries the active stop's metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/emergency-stop/release:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: releaseEmergencyStop
      tags: [Oversight]
      summary: Release the workspace emergency stop
      description: |
        Clears `workspaces.stop_active` so claims and mission starts are
        admitted again, and records the release in the Article-14 oversight
        trail and the hash-chained audit log.
        NOTHING AUTO-RESUMES: every mission paused by the stop STAYS paused
        and is resumed (or not) one at a time through the existing per-mission
        operator control, so `missions_resumed` is always 0. Releasing the
        brake is not a decision to proceed.
        SESSION-ONLY, operator-or-above, same as engaging. `notes` is REQUIRED
        (max 2000 characters).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [notes]
              properties:
                notes:
                  type: string
                  minLength: 1
                  maxLength: 2000
                  description: What was resolved. Recorded in the audit chain.
      responses:
        '200':
          description: |
            Stop released. `missions_resumed` is structurally 0 — release
            resumes nothing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  released:
                    type: boolean
                  missions_resumed:
                    type: integer
                    description: Always 0 — releasing the stop never resumes a mission.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: No emergency stop is active for this workspace.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/oversight/plan-hold:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getPlanHoldConfig
      tags: [Oversight]
      summary: Get plan-approval-hold config
      description: |
        Returns the workspace's stored `workspace_policies.plan_approval_hold`
        value (`raw`, null when the workspace has never configured one) plus
        the config the runtime actually applies (`effective`). The production
        default is disabled (`enabled: false`) — no mission holds until an
        operator arms it via the PUT below. Before arming, review the shadow
        evidence: every mission that WOULD have held under an armed config
        writes a `mission.plan_hold_shadow` audit row (see
        docs/oversight/plan-hold-arming-runbook.md for the review query and
        the arming guidance). 404 when the workspace has no default policy
        row.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Stored and effective plan-approval-hold config
          content:
            application/json:
              schema:
                type: object
                properties:
                  raw:
                    description: The stored JSONB value, or null when unconfigured.
                    nullable: true
                  effective:
                    $ref: '#/components/schemas/PlanHoldEffectiveConfig'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: putPlanHoldConfig
      tags: [Oversight]
      summary: Arm or disarm the plan-approval hold
      description: |
        Writes the workspace's plan-approval-hold config onto the default
        policy row. SESSION-ONLY: this is an oversight-parameter decision, so
        it requires a proven interactive human session (login + MFA) — an API
        token of any scope is refused with 403 before anything is written.
        The body is validated field-by-field (all six fields required, the
        three durations positive integers strictly increasing) and
        round-tripped through the runtime resolver: a payload the resolver
        would silently correct is rejected with 400 rather than stored. The
        policy UPDATE, an Article-14 `human_oversight_events` row
        (`parameter_modified`, old/new values, auth channel), and a
        hash-chained audit entry commit in one transaction. 404 when no
        default policy row exists (nothing is auto-created).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlanHoldConfigPayload'
      responses:
        '200':
          description: Config stored; returns the new raw + effective config and the previous raw value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  raw:
                    $ref: '#/components/schemas/PlanHoldConfigPayload'
                  effective:
                    $ref: '#/components/schemas/PlanHoldEffectiveConfig'
                  previous:
                    description: The previously stored raw value (null when none).
                    nullable: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/oversight/policy:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getOversightPolicy
      tags: [Oversight]
      summary: Get the active oversight policy
      description: |
        Returns the workspace's ACTIVE `workspace_oversight_policies` row:
        its `version`, content `hash` (SHA-256 over the
        canonical JSON) and `content`. A workspace with no policy row is
        served the static implicit-defaults descriptor as `version: 0` — an
        honest fallback, never a fabricated row. Also returns
        `pending_change`: the single live `policy_change` proposal (per-key
        diff, proposed hash, votes so far, expiry), or null when none is
        pending. Operator role or above.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Active policy version + the pending change, if any
          content:
            application/json:
              schema:
                type: object
                properties:
                  active:
                    $ref: '#/components/schemas/ActiveOversightPolicy'
                  pending_change:
                    $ref: '#/components/schemas/PendingPolicyChange'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    put:
      operationId: proposeOversightPolicyChange
      tags: [Oversight]
      summary: Propose an oversight-policy change (never applies directly)
      description: |
        Proposes new oversight-policy content. This endpoint NEVER applies a
        change. Content identical to the active policy is a 200 no-op
        (`changed: false`). A real change returns 202 and creates a Tier-4
        DUAL-approver `policy_change` approval request (priority `critical`,
        72h expiry) carrying the per-key diff, the old and proposed hashes, a
        linked mission-less `risk_tier_decisions` row, and the policy stamps
        in force at proposal time. Applying happens only after two DISTINCT
        interactive humans approve, via the approve endpoint below; a single
        veto rejects; an undecided proposal expires and can never be applied.

        SESSION-ONLY: changing the oversight regime is itself an oversight
        decision, so a proven interactive human session (login + MFA) is
        required — an API token of any scope is refused with 403 before
        anything is written. Only one live proposal at a time (409 otherwise).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OversightPolicyContent'
      responses:
        '200':
          description: |
            Proposed content is byte-identical to the active policy — nothing
            was proposed and nothing was applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  changed:
                    type: boolean
                    description: Always false on this response.
                  note:
                    type: string
                  active:
                    type: object
                    properties:
                      version:
                        type: integer
                      hash:
                        type: string
        '202':
          description: |
            Change proposed. A Tier-4 dual-approver approval request was
            created; nothing has been applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvalId:
                    type: string
                    format: uuid
                  dualApproverRequired:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: A policy-change proposal is already pending for this workspace.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/oversight/policy/approvals/{approvalId}/approve:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/ApprovalId'
    post:
      operationId: approveOversightPolicyChange
      tags: [Oversight]
      summary: Vote to approve a pending oversight-policy change
      description: |
        Records one approver's vote on a pending `policy_change` request.
        `policy_change` is ALWAYS dual-approver, so there is no
        single-approver branch: the first vote returns 202 with nothing
        applied, and the second vote from a DISTINCT human decides the
        request and applies the change through the orchestrator's
        workspace-scoped approval branch (hash re-verification, stale-base
        refusal, new immutable version row + `is_active` flip, a
        workspace-level `human_oversight_events` row and an
        `oversight.policy_applied` audit entry, all in one armed
        transaction). A second vote by the same human is 409. An expired
        proposal is undecidable (409).

        API tokens receive 403 regardless of scope — oversight decisions
        require an interactive MFA session, so this endpoint carries
        SessionCookieAuth only.
      security:
        - SessionCookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  maxLength: 2000
                  description: Optional approver rationale, stored as `decision_notes`.
      responses:
        '200':
          description: Second distinct approval recorded; the policy change was applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approval:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      status:
                        type: string
                      approvers:
                        type: array
                        items:
                          type: string
                  applied:
                    type: boolean
        '202':
          description: First approval recorded; one more distinct approver is required.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  approvalId:
                    type: string
                    format: uuid
                  approversSoFar:
                    type: integer
                  requiredApprovers:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Already decided, expired, a duplicate vote by the same human, or a
            malformed approver history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/oversight/policy/approvals/{approvalId}/reject:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/ApprovalId'
    post:
      operationId: rejectOversightPolicyChange
      tags: [Oversight]
      summary: Veto a pending oversight-policy change
      description: |
        Rejects a pending `policy_change` request. A SINGLE veto is enough —
        stopping never gets harder than proceeding, so no second denier is
        required even though approving needs two. NOTHING is applied. The
        compare-and-set UPDATE, a workspace-level `human_oversight_events` row
        (`approval_denied`) and a hash-chained audit entry commit in one
        transaction.

        API tokens receive 403 regardless of scope — oversight decisions
        require an interactive MFA session, so this endpoint carries
        SessionCookieAuth only.
      security:
        - SessionCookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 2000
                  description: |
                    Optional operator rationale, stored as `decision_notes`.
                    Defaults to "Denied by operator".
      responses:
        '200':
          description: Proposal rejected; nothing was applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approval:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      status:
                        type: string
                  applied:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Proposal not found, in another workspace, or already decided.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/missions/{missionId}/approvals/{approvalId}/reject:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
      - $ref: '#/components/parameters/ApprovalId'
    post:
      operationId: rejectApproval
      tags: [Oversight]
      summary: Reject (deny) approval request
      description: |
        Denies a pending Tier 3/4 approval request (EU AI Act Article 14 human
        oversight), mirroring the mission approve endpoint. A single operator's
        reject is a veto: it rejects the request outright regardless of
        `approval_tier` — unlike approve, dual control is never required to
        stop. The decision is a race-safe compare-and-set UPDATE (only a
        currently-`pending` row in this workspace/mission is rejected) and
        writes both a `human_oversight_events` row (`event_type:
        approval_denied`) and a hash-chained, append-only `audit_events` entry in the same
        transaction as the decision. Does not change the mission's state and
        makes no orchestrator call — there is no MissionState.REJECTED.

        API tokens receive 403 regardless of scope — this is deliberate
        (oversight decisions require an interactive MFA session, so this
        endpoint carries SessionCookieAuth only).
      security:
        - SessionCookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 2000
                  description: |
                    Optional operator rationale, stored as `decision_notes` and
                    in the audit metadata. Defaults to "Denied by operator".
                client_dwell_ms:
                  type: integer
                  minimum: 1
                  maximum: 86400000
                  description: |
                    ADVISORY-ONLY oversight telemetry (Art. 14(4)(b)
                    automation-bias countermeasure): how many milliseconds the
                    decision packet was visible in the operator's console
                    before this click. Client-reported and therefore
                    spoofable — it is recorded as disclosure, never used to
                    gate the decision and never an input to any compliance
                    verdict. There is no column for it: it is folded into the
                    approval row's `context` jsonb under `_telemetry`
                    (`{client_dwell_ms, advisory: true, recorded_at}`) in the
                    SAME decision UPDATE. A missing, non-numeric or
                    out-of-range value is IGNORED — it never produces a 400,
                    because bad telemetry must not block a human decision.
                webauthn:
                  allOf:
                    - $ref: '#/components/schemas/DecisionWebauthnAssertion'
                  description: |
                    Optional per-decision WebAuthn assertion. Rejecting NEVER
                    requires one, in any `webauthn` mode — stopping must never
                    be harder than proceeding, so `required` gates approve
                    only and a plain reject takes the unchanged path. An
                    assertion attached voluntarily IS verified in every mode
                    and, when valid, is recorded with the veto; one that fails
                    verification is a 403 with NO decision recorded, and the
                    operator can always re-send the reject without it.
      responses:
        '200':
          description: Approval rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  approval:
                    $ref: '#/components/schemas/ApprovalRequestDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Approval not found, in another workspace/mission, or already
            decided (a repeat reject is a safe no-op, never a double-write).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces/{workspaceSlug}/compliance/{missionId}/oversight:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/MissionId'
    post:
      operationId: logOversightEvent
      tags: [Oversight]
      summary: Log human oversight event
      description: |
        Records an EU AI Act Article 14 human-oversight event against a
        mission in the caller's workspace (operator role or above; the
        mission must belong to the workspace, else 404). This is the
        awareness/record channel — it never decides an approval; decisions
        go through the session-only approve/reject endpoints.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eventType]
              properties:
                eventType:
                  type: string
                  description: >-
                    Oversight event type; must be a value accepted by the
                    `human_oversight_events.event_type` CHECK constraint
                    (e.g. `mission_paused`, `mission_resumed`,
                    `agent_output_overridden`, `approval_granted`,
                    `approval_denied`).
                targetTaskId:
                  type: string
                  format: uuid
                reason:
                  type: string
                originalValue:
                  description: Prior value, stored as JSON
                newValue:
                  description: Replacement value, stored as JSON
      responses:
        '201':
          description: Oversight event recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  eventType:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/search:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: search
      tags: [Search]
      summary: Search
      description: |
        Execute advanced search across missions, artifacts, audit events, and webhooks.
        Supports query syntax: "exact phrases", +required terms, -excluded terms, field:value filters.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /workspaces/{workspaceSlug}/search/suggestions:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getSearchSuggestions
      tags: [Search]
      summary: Search suggestions
      description: Get autocomplete suggestions based on partial query
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 100
          description: Partial query string
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
            maximum: 20
      responses:
        '200':
          description: Suggestions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchSuggestionsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/search/analytics:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getSearchAnalytics
      tags: [Search]
      summary: Search analytics
      description: Get search usage analytics for the workspace
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 30
            maximum: 90
          description: Number of days to analyze
      responses:
        '200':
          description: Search analytics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchAnalytics'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/validate:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkValidate
      tags: [Bulk Operations]
      summary: Validate bulk operation
      description: Preview a bulk operation without executing it
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkOperationRequest'
      responses:
        '200':
          description: Validation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationValidation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/execute:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkExecute
      tags: [Bulk Operations]
      summary: Execute bulk operation
      description: Execute a batch operation on missions, artifacts, or notifications
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkOperationRequest'
      responses:
        '202':
          description: Operation queued for execution
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /workspaces/{workspaceSlug}/bulk/operations:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: bulkListOperations
      tags: [Bulk Operations]
      summary: List bulk operations
      description: List recent bulk operations with optional filtering
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, queued, processing, completed, partial, failed, cancelled]
      responses:
        '200':
          description: List of bulk operations
          content:
            application/json:
              schema:
                type: object
                properties:
                  operations:
                    type: array
                    items:
                      $ref: '#/components/schemas/BulkOperationStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/operations/{operationId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: operationId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: bulkGetOperation
      tags: [Bulk Operations]
      summary: Get bulk operation status
      description: Get detailed status of a bulk operation including progress
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Operation status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/bulk/operations/{operationId}/cancel:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: operationId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      operationId: bulkCancelOperation
      tags: [Bulk Operations]
      summary: Cancel bulk operation
      description: Cancel a queued or running bulk operation
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Operation cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/missions/cancel:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkCancelMissions
      tags: [Bulk Operations]
      summary: Bulk cancel missions
      description: Cancel multiple missions at once
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkMissionsRequest'
      responses:
        '202':
          description: Cancel operation queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/missions/delete:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkDeleteMissions
      tags: [Bulk Operations]
      summary: Bulk delete missions
      description: Soft-delete multiple missions at once
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkMissionsRequest'
      responses:
        '202':
          description: Delete operation queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/artifacts/delete:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkDeleteArtifacts
      tags: [Bulk Operations]
      summary: Bulk delete artifacts
      description: Delete multiple artifacts at once
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkArtifactsRequest'
      responses:
        '202':
          description: Delete operation queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/bulk/notifications/mark-read:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: bulkMarkNotificationsRead
      tags: [Bulk Operations]
      summary: Bulk mark notifications as read
      description: Mark multiple notifications as read at once
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkNotificationsRequest'
      responses:
        '202':
          description: Mark-read operation queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOperationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /workspaces/{workspaceSlug}/notifications/templates:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listNotificationTemplates
      tags: [Notifications]
      summary: List notification templates
      description: List all notification templates in the workspace
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: List of templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      $ref: '#/components/schemas/NotificationTemplate'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createNotificationTemplate
      tags: [Notifications]
      summary: Create notification template
      description: Create a new reusable notification template
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTemplateRequest'
      responses:
        '201':
          description: Template created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationTemplate'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/notifications/templates/{id}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getNotificationTemplate
      tags: [Notifications]
      summary: Get notification template
      description: Get a specific notification template
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Template details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationTemplate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/notifications/channels:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listNotificationChannels
      tags: [Notifications]
      summary: List notification channels
      description: List all notification channels in the workspace
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: List of channels
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: array
                    items:
                      $ref: '#/components/schemas/NotificationChannel'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createNotificationChannel
      tags: [Notifications]
      summary: Create notification channel
      description: Create a new notification channel (Email, Slack, Webhook)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateChannelRequest'
      responses:
        '201':
          description: Channel created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationChannel'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/notifications/channels/{id}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getNotificationChannel
      tags: [Notifications]
      summary: Get notification channel
      description: Get a specific notification channel (config is redacted)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Channel details (without sensitive config)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationChannelSafe'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deactivateNotificationChannel
      tags: [Notifications]
      summary: Deactivate notification channel
      description: >-
        Deactivate a notification channel (operator role or higher). Soft
        delete: the channel's is_active flag flips to false so delivery-history
        rows keep their channel reference and the row stays auditable. Secret
        rotation for webhook channels uses this with create: POST a replacement
        channel carrying the new secret, then DELETE the old one.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Channel deactivated
          content:
            application/json:
              schema:
                type: object
                required: [id, isActive]
                properties:
                  id:
                    type: string
                    format: uuid
                  isActive:
                    type: boolean
                    description: Always false after deactivation
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/notifications/send:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: sendNotification
      tags: [Notifications]
      summary: Send notification
      description: Send a notification immediately or queue for delivery
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendNotificationRequest'
      responses:
        '202':
          description: Notification queued for delivery
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationSendResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/notifications/analytics:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getNotificationAnalytics
      tags: [Notifications]
      summary: Notification analytics
      description: Get delivery statistics and analytics
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 30
      responses:
        '200':
          description: Notification analytics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationAnalytics'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/notifications/history:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getNotificationHistory
      tags: [Notifications]
      summary: Notification history
      description: Get delivery history with filtering
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, queued, sending, sent, delivered, failed, retrying]
        - name: channel
          in: query
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Notification history
          content:
            application/json:
              schema:
                type: object
                properties:
                  history:
                    type: array
                    items:
                      $ref: '#/components/schemas/NotificationHistoryEntry'
                  total:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
  /workspaces/{workspaceSlug}/notifications/test:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: sendNotificationChannelTest
      tags: [Notifications]
      summary: Send one real notification to one configured channel and report the transport outcome
      description: >
        The settings form could say "saved" but never "delivers". This route sends a real message
        through the chosen channel and returns what the transport actually did — never a boolean.
        `status` is one of four measured states: `sent` (the endpoint accepted it), `failed` (it
        refused, or the destination was refused by egress policy), `skipped` (nothing was
        attempted, because the channel is unset on this workspace or its transport has no
        credentials) and `uncertain` (no answer inside the 15-second budget, so the endpoint may
        or may not hold the message). Admin role. Consumes a per-workspace test budget that is
        shared with the push test route.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel]
              properties:
                channel:
                  type: string
                  enum: [email, slack, webhook]
      responses:
        '200':
          description: >
            What the transport actually did. A non-delivery is still a 200 — the REQUEST
            succeeded, and the caller needs to know which of the four delivery states happened.
          content:
            application/json:
              schema:
                type: object
                required: [channel, status]
                properties:
                  channel:
                    type: string
                    enum: [email, slack, webhook]
                  status:
                    type: string
                    enum: [sent, failed, skipped, uncertain]
                  http_status:
                    type: integer
                    description: Present only when the destination answered with one
                  error:
                    type: string
                    description: Why it failed or was skipped. Never echoes the configured destination.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
  /workspaces/{workspaceSlug}/notifications/deliveries:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listNotificationDeliveries
      tags: [Notifications]
      summary: Settled outcomes of the most recent mission-completion dispatches
      description: >
        Reads the settled outcome recorded against each NOTIFICATION_DISPATCH_CLAIM row, so a
        channel that has been failing on every mission is visible without running one. A row whose
        `delivery_status` is null was claimed and never settled — a real, distinct state, reported
        as such rather than as a success. No destination address is returned: the row does not
        carry one, and the caller already configured the three addresses. Admin role.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          description: Clamped to 1..100; defaults to 20
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: Recent dispatch outcomes, newest first
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items:
                      type: object
                      required: [mission_id, created_at]
                      properties:
                        mission_id:
                          type: string
                        channel:
                          type: string
                          nullable: true
                        mission_state:
                          type: string
                          nullable: true
                        delivery_status:
                          type: string
                          nullable: true
                          description: null means the claim was never settled
                        http_status:
                          type: integer
                          nullable: true
                        error:
                          type: string
                          nullable: true
                        settled_at:
                          type: string
                          format: date-time
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /workspaces/{workspaceSlug}/notifications/push/config:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getPushConfig
      tags: [Notifications]
      summary: Whether this deployment can send Web Push, and the VAPID public key
      description: >
        Returns the deployment's RFC 8292 application-server public key so a browser can call
        `pushManager.subscribe()`. `configured: false` with a null key means no usable VAPID trio
        (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT) is present on the API service, and
        no browser can be subscribed. Answers 200 in BOTH states deliberately: a 503 would make
        "not configured" indistinguishable from "the request failed", and the settings badge is
        derived from this value. The public key is public by design — it is the identity a push
        service verifies the signed JWT against, not a secret. Any workspace member.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: The measured push configuration of this deployment
          content:
            application/json:
              schema:
                type: object
                required: [configured, public_key]
                properties:
                  configured:
                    type: boolean
                  public_key:
                    type: string
                    nullable: true
                    description: base64url uncompressed P-256 point, or null when unconfigured
        '401':
          $ref: '#/components/responses/Unauthorized'
  /workspaces/{workspaceSlug}/notifications/push/subscriptions:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listPushSubscriptions
      tags: [Notifications]
      summary: The calling user's subscribed browsers in this workspace
      description: >
        Scoped to the authenticated user as well as the workspace — an admin cannot enumerate
        another member's browsers. Neither the push endpoint URL nor the p256dh/auth key material
        is returned: the endpoint is a bearer address for one browser, so callers receive only its
        sha256 digest, which is enough to answer "is this browser subscribed?".
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: This user's subscriptions
          content:
            application/json:
              schema:
                type: object
                required: [subscriptions]
                properties:
                  subscriptions:
                    type: array
                    items:
                      type: object
                      required: [endpoint_digest, created_at, last_seen_at, failure_count]
                      properties:
                        endpoint_digest:
                          type: string
                          description: sha256 hex of the push endpoint URL
                        user_agent:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        last_seen_at:
                          type: string
                          format: date-time
                        last_success_at:
                          type: string
                          format: date-time
                          nullable: true
                        last_failure_at:
                          type: string
                          format: date-time
                          nullable: true
                        failure_count:
                          type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createPushSubscription
      tags: [Notifications]
      summary: Register (or refresh) one browser for Web Push
      description: >
        Idempotent on (workspace, user, sha256 of endpoint): a browser that re-subscribes after a
        `pushsubscriptionchange`, or a second tab, UPDATES the stored key material instead of
        adding a row. The endpoint is browser-supplied and the backend later POSTs to it, so it is
        validated as an egress destination at write time (https, public address, no embedded
        credentials) and again at send time; keys are rejected unless p256dh decodes to an
        uncompressed P-256 point and auth to a 16-octet secret.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint, keys]
              properties:
                endpoint:
                  type: string
                  maxLength: 2048
                keys:
                  type: object
                  required: [p256dh, auth]
                  properties:
                    p256dh:
                      type: string
                    auth:
                      type: string
                user_agent:
                  type: string
                  maxLength: 256
      responses:
        '200':
          description: The subscription is stored (new or refreshed)
          content:
            application/json:
              schema:
                type: object
                required: [endpoint_digest]
                properties:
                  endpoint_digest:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /workspaces/{workspaceSlug}/notifications/push/subscriptions/{id}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: id
        in: path
        required: true
        description: sha256 hex digest of the push endpoint URL — the `endpoint_digest` this API returns
        schema:
          type: string
          pattern: '^[0-9a-f]{64}$'
    delete:
      operationId: deletePushSubscription
      tags: [Notifications]
      summary: Unsubscribe one browser
      description: >
        Deletes by endpoint digest, scoped to the calling user. Returns 200 with `removed: 0` when
        no row matched rather than 404 — a browser cleaning up after a local unsubscribe must not
        see an error for succeeding at what it asked for.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: How many rows were removed
          content:
            application/json:
              schema:
                type: object
                required: [removed]
                properties:
                  removed:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /workspaces/{workspaceSlug}/notifications/push/test:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: sendPushTest
      tags: [Notifications]
      summary: Send a real test push to the caller's own browsers
      description: >
        Uses the SAME sender the mission fan-out uses — a test button with its own send path is how
        a green button coexists with a dead channel. The response is the real transport tally, not
        a boolean. `pruned` counts subscriptions the push service reported as gone (404/410); those
        rows are deleted. `skippedReason: vapid_not_configured` means nothing was attempted because
        the deployment has no signing key. Rate limited per workspace.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: What the push transport actually did
          content:
            application/json:
              schema:
                type: object
                required: [attempted, sent, pruned, failed]
                properties:
                  attempted:
                    type: integer
                  sent:
                    type: integer
                  pruned:
                    type: integer
                  failed:
                    type: integer
                  skippedReason:
                    type: string
                    enum: [vapid_not_configured]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: Too many test sends
  /workspaces/{workspaceSlug}/webhooks:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listWebhookSubscriptions
      tags: [Webhooks]
      summary: List webhook subscriptions
      description: List all webhook subscriptions for the workspace
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: active
          in: query
          schema:
            type: boolean
          description: Filter by active status
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of webhook subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createWebhookSubscription
      tags: [Webhooks]
      summary: Create webhook subscription
      description: Subscribe to receive webhook events
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Subscription created
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription:
                    $ref: '#/components/schemas/WebhookSubscription'
                  secret:
                    type: string
                    description: Webhook signing secret (shown only once)
                  validation:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [pending, success, failed]
                  warning:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    get:
      operationId: getWebhookSubscription
      tags: [Webhooks]
      summary: Get webhook subscription
      description: Get details of a specific webhook subscription
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Subscription details
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription:
                    $ref: '#/components/schemas/WebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateWebhookSubscription
      tags: [Webhooks]
      summary: Update webhook subscription
      description: >
        Update webhook URL, events, active flag, or delivery configuration.
        Validates the request body against the update schema and performs a real
        DB write on `webhook_subscriptions`. Returns the updated subscription.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWebhookRequest'
      responses:
        '200':
          description: Subscription updated
          content:
            application/json:
              schema:
                type: object
                required: [subscription]
                properties:
                  subscription:
                    $ref: '#/components/schemas/WebhookSubscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deleteWebhookSubscription
      tags: [Webhooks]
      summary: Delete webhook subscription
      description: Delete a webhook subscription (admin only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '204':
          description: Subscription deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/rotate-secret:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    post:
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      summary: Rotate webhook secret
      description: Generate new signing secret with 24-hour grace period for old secret
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Secret rotated
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret:
                    type: string
                  previous_secret_expires_at:
                    type: string
                    format: date-time
                  warning:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/analytics:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    get:
      operationId: getWebhookAnalytics
      tags: [Webhooks]
      summary: Get webhook analytics
      description: Get delivery statistics for a webhook subscription
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 7
            maximum: 30
      responses:
        '200':
          description: Webhook analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  analytics:
                    $ref: '#/components/schemas/WebhookAnalytics'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/deliveries:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    get:
      operationId: getWebhookDeliveryHistory
      tags: [Webhooks]
      summary: Get delivery history
      description: >
        Paginated list of webhook delivery attempts from the `webhook_deliveries`
        table, ordered newest-first.  Use `cursor` (an ISO-8601 `created_at`
        timestamp from the previous page's last item) for subsequent pages.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
          description: Maximum number of deliveries to return
        - name: cursor
          in: query
          schema:
            type: string
            format: date-time
          description: Cursor for pagination (created_at of the last item on the previous page)
      responses:
        '200':
          description: Delivery history
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookDelivery'
                  next_cursor:
                    type: string
                    format: date-time
                    nullable: true
                    description: Pass as `cursor` to fetch the next page; null when no more pages
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/deliveries/{deliveryId}/replay:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
      - name: deliveryId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      operationId: replayWebhookDelivery
      tags: [Webhooks]
      summary: Replay delivery
      description: Replay a failed webhook delivery
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Replay queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  new_delivery_id:
                    type: string
                    format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/bulk-retry:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    post:
      operationId: bulkRetryWebhookDeliveries
      tags: [Webhooks]
      summary: Bulk retry failed deliveries
      description: Retry all failed deliveries within a subscription
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                max_retries:
                  type: integer
                  default: 100
      responses:
        '200':
          description: Bulk retry result
          content:
            application/json:
              schema:
                type: object
                properties:
                  retried:
                    type: integer
                  failed:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/webhooks/{subscriptionId}/ping:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/SubscriptionId'
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Test webhook
      description: >
        Inserts a real delivery row into `webhook_deliveries` with
        event_type='ping', status='pending', and a valid HMAC signature.
        Returns the actual delivery UUID so callers can track the attempt.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Ping delivery inserted
          content:
            application/json:
              schema:
                type: object
                required: [status, delivery_id, timestamp]
                properties:
                  status:
                    type: string
                    description: Delivery status (pending on first insert)
                  delivery_id:
                    type: string
                    format: uuid
                    description: UUID of the created delivery row in webhook_deliveries
                  timestamp:
                    type: string
                    format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/audit:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listAuditEvents
      tags: [Audit]
      summary: List audit events
      description: List audit events with filtering (admin only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: missionId
          in: query
          schema:
            type: string
            format: uuid
        - name: action
          in: query
          schema:
            type: string
        - name: actorType
          in: query
          schema:
            type: string
            enum: [user, agent, system, worker]
      responses:
        '200':
          description: Audit events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuditEvent'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/audit/verify:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: verifyAuditChain
      tags: [Audit]
      summary: Verify audit chain
      description: Verify cryptographic integrity of the audit chain (admin only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditChainVerification'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/audit/attestation-cert:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getAttestationCert
      tags: [Audit]
      summary: Get attestation signing certificate
      description: >
        Returns the public X.509 certificate (PEM) used to sign PDF attestations.
        Anyone with this certificate can verify a signed attestation PDF offline
        using standard PKCS#7 verification tools (e.g. openssl, Adobe Reader).
        The signature embedded in the PDF uses RSA-SHA256 (PKCS#7 detached, adbe.pkcs7.detached).
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Active attestation signing certificate
          content:
            application/json:
              schema:
                type: object
                required: [pem, expires]
                properties:
                  pem:
                    type: string
                    description: PEM-encoded X.509 public certificate
                    example: "-----BEGIN CERTIFICATE-----\nMIIC...==\n-----END CERTIFICATE-----\n"
                  expires:
                    type: string
                    format: date-time
                    description: Certificate expiry as ISO 8601 timestamp
                    example: "2028-04-26T10:00:00.000Z"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/integrations:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listIntegrations
      tags: [Integrations]
      summary: List integrations
      description: List all configured integrations for the workspace
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: List of integrations
          content:
            application/json:
              schema:
                type: object
                properties:
                  integrations:
                    type: array
                    items:
                      $ref: '#/components/schemas/Integration'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/integrations/github:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: registerGitHubIntegration
      tags: [Integrations]
      summary: Register GitHub integration
      description: Register or update GitHub integration with token validation
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterGitHubRequest'
      responses:
        '201':
          description: Integration registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  integration:
                    $ref: '#/components/schemas/Integration'
                  validated:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          description: Secrets encryption not configured

  /workspaces/{workspaceSlug}/integrations/{integrationId}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - name: integrationId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      operationId: deleteIntegration
      tags: [Integrations]
      summary: Delete integration
      description: Delete an integration (admin only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '204':
          description: Integration deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/admin/quota:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getQuotaStatus
      tags: [Workspace Admin]
      summary: Get quota status
      description: Get current quota usage and limits
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Quota status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuotaStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/admin/quota/history:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getQuotaHistory
      tags: [Workspace Admin]
      summary: Get quota history
      description: Get historical quota usage
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Quota history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuotaHistory'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/admin/status:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: getWorkspaceStatus
      tags: [Workspace Admin]
      summary: Get workspace status
      description: Get detailed workspace status and health
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Workspace status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkspaceStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /workspaces/{workspaceSlug}/admin/suspend:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: suspendWorkspace
      tags: [Workspace Admin]
      summary: Suspend workspace
      description: Suspend workspace operations (owner only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Workspace suspended
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Workspace'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/admin/resume:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: resumeWorkspace
      tags: [Workspace Admin]
      summary: Resume workspace
      description: Resume workspace operations (owner only)
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Workspace resumed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Workspace'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/admin/pause-missions:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: pauseMissions
      tags: [Workspace Admin]
      summary: Pause missions
      description: Pause all new mission creation while preserving running missions
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: Missions paused
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /workspaces/{workspaceSlug}/schedules:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    get:
      operationId: listSchedules
      tags: [Schedules]
      summary: List mission schedules
      description: >-
        Every cron mission schedule in the workspace, newest first
        (`ORDER BY created_at DESC`). Requires the `member` role or higher.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '200':
          description: The workspace's schedules
          content:
            application/json:
              schema:
                type: object
                required: [schedules]
                properties:
                  schedules:
                    type: array
                    items:
                      $ref: '#/components/schemas/MissionSchedule'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      operationId: createSchedule
      tags: [Schedules]
      summary: Create a mission schedule
      description: |
        Requires the `operator` role or higher. The cron expression is validated
        and its first run computed before the row is written; `timezone`
        defaults to `UTC` and the resolved zone is echoed back so the client can
        render `next_run_at` in the zone it was computed under. `is_active` is
        stored as sent (default `true`).

        Creating a schedule is not itself subject to the launch approval gate;
        each mission the scheduler starts from it passes the gate at start time
        (origin `schedule`).
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, goal, cron_expression]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 128
                goal:
                  type: string
                  minLength: 10
                  maxLength: 5000
                  description: >-
                    The schedules route's own bound, a known divergence from the
                    composer ceiling that `POST /missions` documents.
                template:
                  type: string
                  minLength: 1
                  maxLength: 64
                  default: custom
                cron_expression:
                  type: string
                  minLength: 1
                  maxLength: 128
                timezone:
                  type: string
                  maxLength: 64
                  description: IANA zone name. Defaults to `UTC` when omitted.
                is_active:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [id, name, cron_expression, timezone, next_run_at]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  cron_expression:
                    type: string
                  timezone:
                    type: string
                    description: The zone the schedule was stored with (`UTC` when none was sent).
                  next_run_at:
                    type: string
                    format: date-time
                    description: First run, computed under `timezone`.
        '400':
          description: >-
            `InvalidInput` — the body fails validation; `InvalidCron` — the cron
            expression does not parse in the given zone, or never fires.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [InvalidInput, InvalidCron]
                  message:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/schedules/validate-cron:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
    post:
      operationId: validateCron
      tags: [Schedules]
      summary: Validate a cron expression and preview its next five runs
      description: >-
        Dry validation used by the console's cron builder; writes nothing.
        Requires the `member` role or higher. An expression that does not parse
        is still a 200 with `valid: false` and `error`; 400 is only for a
        missing `cron_expression`.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cron_expression]
              properties:
                cron_expression:
                  type: string
                timezone:
                  type: string
                  description: IANA zone name. Defaults to `UTC` when omitted.
      responses:
        '200':
          description: Validation verdict
          content:
            application/json:
              schema:
                type: object
                required: [valid]
                properties:
                  valid:
                    type: boolean
                  fields:
                    type: object
                    description: Parsed cron fields (present when valid).
                    required: [minute, hour, dayOfMonth, month, dayOfWeek]
                    properties:
                      second:
                        type: array
                        items:
                          type: integer
                      minute:
                        type: array
                        items:
                          type: integer
                      hour:
                        type: array
                        items:
                          type: integer
                      dayOfMonth:
                        type: array
                        items:
                          oneOf:
                            - type: integer
                            - type: string
                      month:
                        type: array
                        items:
                          type: integer
                      dayOfWeek:
                        description: >-
                          Numeric weekdays, plus the string `L` when the expression uses the
                          last-occurrence form (e.g. `5L`). Same widening as `dayOfMonth`.
                        type: array
                        items:
                          oneOf:
                            - type: integer
                            - type: string
                  nextRuns:
                    type: array
                    description: The next five run instants, ISO 8601 (present when valid).
                    items:
                      type: string
                      format: date-time
                  error:
                    type: string
                    description: Parser error (present when invalid).
        '400':
          description: '`InvalidInput` — `cron_expression` missing or not a string.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /workspaces/{workspaceSlug}/schedules/{id}:
    parameters:
      - $ref: '#/components/parameters/WorkspaceSlug'
      - $ref: '#/components/parameters/ScheduleId'
    put:
      operationId: updateSchedule
      tags: [Schedules]
      summary: Update a mission schedule (partial)
      description: |
        Requires the `operator` role or higher. Only the keys present in the
        body are written; absent keys are left untouched (a rename does not
        reactivate a deactivated schedule or reset its template). A body with
        no updatable key is 400 `InvalidInput`.

        When `cron_expression` is sent it is validated and `next_run_at` is
        recomputed under the `timezone` sent in the same request, or `UTC`
        when none is sent — not under the schedule's stored zone. A `timezone`
        sent on its own is stored without recomputing `next_run_at`.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 128
                goal:
                  type: string
                  minLength: 10
                  maxLength: 5000
                template:
                  type: string
                  minLength: 1
                  maxLength: 64
                cron_expression:
                  type: string
                  minLength: 1
                  maxLength: 128
                timezone:
                  type: string
                  maxLength: 64
                is_active:
                  type: boolean
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [id, updated]
                properties:
                  id:
                    type: string
                    format: uuid
                  updated:
                    type: boolean
                    enum: [true]
        '400':
          description: >-
            `InvalidInput` — the body fails validation or contains no updatable
            key; `InvalidCron` — `cron_expression` does not parse; `BadRequest`
            — `id` is not a UUID.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [InvalidInput, InvalidCron, BadRequest]
                  message:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            No schedule with this `id` in the workspace, or the workspace was
            not found. The schedule-level body carries only `error`.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum: [NotFound]
                  message:
                    type: string
    delete:
      operationId: deleteSchedule
      tags: [Schedules]
      summary: Delete a mission schedule
      description: Requires the `operator` role or higher. Deletion is not gated.
      security:
        - SessionCookieAuth: []
        - ApiTokenAuth: []
      responses:
        '204':
          description: Deleted
        '400':
          description: '`BadRequest` — `id` is not a UUID.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            No schedule with this `id` in the workspace, or the workspace was
            not found. The schedule-level body carries only `error`.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum: [NotFound]
                  message:
                    type: string

components:
  securitySchemes:
    SessionCookieAuth:
      type: apiKey
      in: cookie
      name: hm_session
      description: >-
        Hivemind web session cookie (AUTH_MODE=session). HttpOnly, Secure,
        SameSite=Lax; issued by POST /auth/login (after any required MFA
        step-up) and sent automatically by the browser on same-site requests.
        Used by the first-party console.
    ApiTokenAuth:
      type: http
      scheme: bearer
      description: >-
        Hivemind API token, prefix hmt_. Opaque bearer credential for
        programmatic clients (SDK, CLI, MCP-over-HTTP) that cannot hold a
        browser session cookie. Sent as `Authorization: Bearer hmt_...`; only
        a SHA-256 digest is stored server-side and the plaintext is shown
        once at issuance.

  parameters:
    WorkspaceSlug:
      name: workspaceSlug
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-z0-9-]+$'
        minLength: 3
        maxLength: 63
      description: Workspace unique slug identifier

    MissionId:
      name: missionId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Mission unique identifier

    ArtifactId:
      name: artifactId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Artifact unique identifier

    SubscriptionId:
      name: subscriptionId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Webhook subscription unique identifier

    ApprovalId:
      name: approvalId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Approval request unique identifier

    TaskId:
      name: taskId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Task unique identifier

    ScheduleId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Mission schedule unique identifier (a malformed value is 400 `BadRequest`)

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    Forbidden:
      description: Access denied
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    QuotaExceeded:
      description: Monthly quota exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    PolicyViolation:
      description: Request violates workspace policy
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    RateLimitExceeded:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    MissionSchedule:
      type: object
      description: >-
        A `mission_schedules` row as returned by `GET /schedules`. The timestamp
        fields are Postgres text renderings (`timestamptz::text`, e.g.
        `2026-09-03 09:00:00+00`), not ISO 8601.
      required: [id, workspace_id, name, goal, template, cron_expression, timezone, is_active, next_run_at, last_run_at, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
          nullable: true
        goal:
          type: string
        template:
          type: string
        cron_expression:
          type: string
        timezone:
          type: string
          description: IANA zone name the schedule runs under (column default `UTC`).
        is_active:
          type: boolean
        next_run_at:
          type: string
        last_run_at:
          type: string
          nullable: true
        created_at:
          type: string
        updated_at:
          type: string

    Pagination:
      type: object
      properties:
        limit:
          type: integer
        offset:
          type: integer
        total:
          type: integer

    ErrorResponse:
      type: object
      required:
        - error
        - message
      properties:
        error:
          type: string
          description: Stable error code for programmatic handling
          example: QuotaExceeded
        message:
          type: string
          description: Human-readable error description
          example: Monthly budget limit reached (85.5% used)
        details:
          type: object
          description: Additional error context
        requestId:
          type: string
          description: Request ID for tracing

    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        status:
          type: string
          enum: [active, suspended, pending]
        created_at:
          type: string
          format: date-time
      description: |
        User profile information. Note: Workspace roles (owner, admin, member, viewer)
        are context-specific and available via workspace membership endpoints.

    Workspace:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        owner_id:
          type: string
          format: uuid
        plan:
          type: string
        status:
          type: string
          enum: [active, suspended, pending_deletion]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WorkspaceStatus:
      type: object
      properties:
        workspace:
          $ref: '#/components/schemas/Workspace'
        health:
          type: object
          properties:
            status:
              type: string
              enum: [healthy, degraded, critical]
            components:
              type: object

    QuotaStatus:
      type: object
      properties:
        currentUsage:
          type: number
          format: float
        monthlyLimit:
          type: number
          format: float
        remainingBudget:
          type: number
          format: float
        percentUsed:
          type: number
          format: float
        missionsThisMonth:
          type: integer
        artifactsStored:
          type: integer

    QuotaHistory:
      type: object
      properties:
        history:
          type: array
          items:
            type: object
            properties:
              month:
                type: string
              usage:
                type: number
              limit:
                type: number

    MissionState:
      type: string
      description: >-
        The twelve states a mission can be in. `queued` and `awaiting_approval`
        are task and stage states, never mission states, so no handler returns
        either. `plan_review`, `paused` and `waiting_for_input` are exactly the
        states for which `POST /missions/{missionId}/start` answers 200
        already-running.
      enum:
        - created
        - planning
        - plan_review
        - executing
        - paused
        - waiting_for_input
        - verifying
        - delivering
        - completed
        - completed_with_errors
        - failed
        - cancelled

    MissionTemplate:
      type: string
      enum:
        - build-feature
        - refactor
        - security-audit
        - write-report
        - design-architecture
        - incident-analysis
        - code-review
        - infrastructure-setup
        - data-migration
        - performance-optimization

    MissionInputs:
      type: object
      properties:
        repo_url:
          type: string
        branch:
          type: string
        target_branch:
          type: string
        spec:
          type: string
        pr_url:
          type: string
        focus_areas:
          type: array
          items:
            type: string

    MissionConstraints:
      type: object
      properties:
        max_budget_usd:
          type: number
          default: 10
        max_duration_minutes:
          type: integer
          default: 30
        coding_standards_ref:
          type: string
        require_tests:
          type: boolean
          default: true

    Mission:
      type: object
      description: >-
        The mission object every route returns: an allowlist, never the stored row, so no property
        outside this list is ever sent. Every route carries the
        properties up to `completed_at`. `GET /missions` and `GET /missions/{missionId}` add
        `creator_name` and `outcome_states`; `GET /missions/{missionId}` adds `chain_attestation`,
        `model`, `template_name` and `mission_risk_class`. The creator is identified by `created_by`
        and `creator_name` only; no e-mail
        address is sent. Numeric database columns (`numeric`) may arrive as strings.
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        created_by:
          type: string
          format: uuid
          description: User id of the member who created the mission.
        template:
          $ref: '#/components/schemas/MissionTemplate'
        state:
          $ref: '#/components/schemas/MissionState'
        goal:
          type: string
          minLength: 10
          maxLength: 57344
        inputs:
          $ref: '#/components/schemas/MissionInputs'
        constraints:
          $ref: '#/components/schemas/MissionConstraints'
        estimated_cost_usd:
          oneOf:
            - type: number
            - type: string
          nullable: true
        actual_cost_usd:
          oneOf:
            - type: number
            - type: string
        total_cost_usd:
          oneOf:
            - type: number
            - type: string
          nullable: true
          description: The mission's settled total cost in USD.
        risk_score:
          type: number
          nullable: true
        risk_rating:
          type: string
          enum: [LOW, MEDIUM, HIGH, CRITICAL]
          nullable: true
        risk_score_finding_weighted:
          type: number
          nullable: true
          description: 0-100 risk score weighted by the extracted findings.
        confidence:
          type: number
          nullable: true
        current_stage_id:
          type: string
          format: uuid
          nullable: true
        failure_reason:
          type: string
          nullable: true
          description: Why the mission failed, with provider error text redacted.
        executive_summary:
          type: string
          nullable: true
          description: The final answer, with the AI disclosure appended when it travels without a report.
        extracted_findings:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
        created_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        creator_name:
          type: string
          nullable: true
          description: >-
            Display name of the member who created the mission (list and detail reads only); null
            when that user no longer exists.
        outcome_states:
          $ref: '#/components/schemas/MissionOutcomeStates'
        chain_attestation:
          type: object
          nullable: true
          description: >-
            `GET /missions/{missionId}` only. The server-signed root of the mission's audit chain;
            null when signing failed for this read.
          properties:
            chain_root_hash:
              type: string
            signature:
              type: string
            verified_at:
              type: string
              format: date-time
            algorithm:
              type: string
        model:
          type: string
          nullable: true
          description: >-
            `GET /missions/{missionId}` only. The model most of the mission's agent runs used; null
            when no run has recorded one yet.
        template_name:
          type: string
          nullable: true
          description: >-
            `GET /missions/{missionId}` only. The template registry's display name for `template`,
            which reports use as their title; null when the template is not in the registry.
        mission_risk_class:
          type: object
          description: >-
            `GET /missions/{missionId}` only. The mission's own risk class: the oversight input,
            derived from the objective's wording, that decides which plans and actions hold for human
            approval. It is not `risk_rating`, which rates what the findings describe.
          required: [value, source]
          additionalProperties: false
          properties:
            value:
              type: string
              enum: [low, medium, high, critical]
            source:
              type: string
              enum: [derived_from_objective]

    MissionCreateResponse:
      type: object
      required: [mission, created]
      properties:
        mission:
          $ref: '#/components/schemas/Mission'
        created:
          type: boolean
          description: false when an existing mission was returned for the same idempotency key.
        warning:
          type: string
          description: >-
            A launch notice: the primary LLM provider is degraded, or a connection whose credential
            is refused, and which the objective does not name, is left out of the mission.

    MissionOutcomeState:
      type: object
      description: >-
        One outcome check. `value` is true, false or "not_evaluated"; the third value means the
        check could not run (mission still running, no task graph, no evidence check recorded)
        and is never folded into false. `reason` says why in plain language.
      properties:
        value:
          oneOf:
            - type: boolean
            - type: string
              enum: [not_evaluated]
        reason:
          type: string

    MissionOutcomeStates:
      type: object
      nullable: true
      description: >-
        What the mission actually did, as three checks derived from records that can each go red:
        delivered (a deliverable was persisted), grounded (the evidence gate recorded a passing
        verdict) and verified (every task completed with an approved verification verdict and no
        validation failure was auto-continued). These replace the internal quality evaluator's
        number, which is not exposed. See docs/QUALITY.md.
      properties:
        delivered:
          $ref: '#/components/schemas/MissionOutcomeState'
        grounded:
          $ref: '#/components/schemas/MissionOutcomeState'
        verified:
          $ref: '#/components/schemas/MissionOutcomeState'
        evaluated_at:
          type: string
          format: date-time

    CreateMissionRequest:
      type: object
      required:
        - template
        - goal
      properties:
        template:
          $ref: '#/components/schemas/MissionTemplate'
        goal:
          type: string
          minLength: 10
          maxLength: 57344
        inputs:
          type: object
          additionalProperties: true
        data_workspace_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Same three values as on /missions/from-objective: an id reads that
            data workspace only, null reads no document, absent reads the one
            data workspace that holds documents if exactly one does.
        constraints:
          $ref: '#/components/schemas/MissionConstraints'
        tool_scopes:
          type: array
          items:
            type: string
        idempotency_key:
          type: string
          minLength: 8
          maxLength: 255

    Stage:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        mission_id:
          type: string
          format: uuid
        type:
          type: string
          enum: [planning, execution, verification, delivery]
        sequence:
          type: integer
        state:
          type: string
          enum: [pending, active, completed, failed, skipped]
        risk_score:
          type: number
          nullable: true
        input_summary:
          type: string
          nullable: true
        output_summary:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true

    MissionEvidence:
      type: object
      properties:
        mission_id:
          type: string
          format: uuid
        chain_hash:
          type: string
        events:
          type: array
          items:
            type: object

    VerificationResult:
      type: object
      properties:
        valid:
          type: boolean
        algorithm:
          type: string
        verified_at:
          type: string
          format: date-time
        details:
          type: object

    Artifact:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        mission_id:
          type: string
          format: uuid
        task_id:
          type: string
          format: uuid
          nullable: true
        type:
          type: string
        name:
          type: string
        mime_type:
          type: string
        size_bytes:
          type: integer
        content_hash:
          type: string
        storage_path:
          type: string
        metadata:
          type: object
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        signature:
          type: string
          nullable: true
        created_by_type:
          type: string
          nullable: true
        created_by_id:
          type: string
          nullable: true
        verified_at:
          type: string
          format: date-time
          nullable: true

    CreateArtifactRequest:
      type: object
      required:
        - name
        - type
        - mime_type
      properties:
        name:
          type: string
        type:
          type: string
        mime_type:
          type: string
        content:
          type: string
          format: base64
        metadata:
          type: object
        expires_at:
          type: string
          format: date-time

    SearchEntityType:
      type: string
      enum: [all, missions, artifacts, audit, webhooks]

    SearchSortBy:
      type: string
      enum: [relevance, created_at, updated_at, name]

    SearchFilters:
      type: object
      properties:
        missions:
          type: object
          properties:
            states:
              type: array
              items:
                $ref: '#/components/schemas/MissionState'
            templates:
              type: array
              items:
                $ref: '#/components/schemas/MissionTemplate'
            createdBy:
              type: array
              items:
                type: string
                format: uuid
            riskScoreMin:
              type: number
            riskScoreMax:
              type: number
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time
            hasArtifacts:
              type: boolean
        artifacts:
          type: object
          properties:
            types:
              type: array
              items:
                type: string
            mimeTypes:
              type: array
              items:
                type: string
            missionId:
              type: string
              format: uuid
            minSizeBytes:
              type: integer
            maxSizeBytes:
              type: integer
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time
        audit:
          type: object
          properties:
            actions:
              type: array
              items:
                type: string
            actorTypes:
              type: array
              items:
                type: string
            outcomes:
              type: array
              items:
                type: string
            actorIds:
              type: array
              items:
                type: string
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time

    SearchRequest:
      type: object
      required:
        - query
      properties:
        query:
          type: string
          minLength: 2
          maxLength: 500
          description: 'Search query. Supports: "exact phrases", +required, -excluded, field:value'
        entityTypes:
          type: array
          items:
            $ref: '#/components/schemas/SearchEntityType'
          default: [all]
        filters:
          $ref: '#/components/schemas/SearchFilters'
        sortBy:
          allOf:
            - $ref: '#/components/schemas/SearchSortBy'
            - default: relevance
        limit:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
        offset:
          type: integer
          minimum: 0
          maximum: 10000
          default: 0
        fuzzy:
          type: boolean
          default: false
        similarityThreshold:
          type: number
          minimum: 0
          maximum: 1
          default: 0.8

    SearchResult:
      type: object
      properties:
        id:
          type: string
        type:
          $ref: '#/components/schemas/SearchEntityType'
        title:
          type: string
        description:
          type: string
        highlights:
          type: array
          items:
            type: string
        score:
          type: number
        created_at:
          type: string
          format: date-time
        metadata:
          type: object

    SearchResponse:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/SearchResult'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer
        facets:
          type: object
          properties:
            missions:
              type: object
            artifacts:
              type: object
            audit:
              type: object
            webhooks:
              type: object
        query:
          type: object
          properties:
            original:
              type: string
            parsed:
              type: object

    SearchSuggestionsResponse:
      type: object
      properties:
        suggestions:
          type: array
          items:
            type: object
            properties:
              text:
                type: string
              type:
                type: string
              score:
                type: number

    SearchAnalytics:
      type: object
      properties:
        totalQueries:
          type: integer
        uniqueQueries:
          type: integer
        topQueries:
          type: array
          items:
            type: object
            properties:
              query:
                type: string
              count:
                type: integer
        averageResponseTimeMs:
          type: number
        popularFilters:
          type: object

    NotificationChannelType:
      type: string
      enum: [email, slack, webhook]

    NotificationPriority:
      type: string
      enum: [low, normal, high, urgent]

    NotificationTemplate:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        channel:
          $ref: '#/components/schemas/NotificationChannelType'
        subjectTemplate:
          type: string
        bodyTemplate:
          type: string
        htmlTemplate:
          type: string
        variables:
          type: array
          items:
            type: string
        defaultPriority:
          $ref: '#/components/schemas/NotificationPriority'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateTemplateRequest:
      type: object
      required:
        - name
        - channel
        - subjectTemplate
        - bodyTemplate
      properties:
        name:
          type: string
        description:
          type: string
        channel:
          $ref: '#/components/schemas/NotificationChannelType'
        subjectTemplate:
          type: string
        bodyTemplate:
          type: string
        htmlTemplate:
          type: string
        variables:
          type: array
          items:
            type: string
        defaultPriority:
          $ref: '#/components/schemas/NotificationPriority'

    NotificationChannel:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        channelType:
          $ref: '#/components/schemas/NotificationChannelType'
        config:
          type: object
        rateLimitPerMinute:
          type: integer
        rateLimitBurst:
          type: integer
        maxRetries:
          type: integer
        retryDelaySeconds:
          type: integer
        created_at:
          type: string
          format: date-time

    NotificationChannelSafe:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        channelType:
          $ref: '#/components/schemas/NotificationChannel'
        rateLimitPerMinute:
          type: integer
        rateLimitBurst:
          type: integer
        maxRetries:
          type: integer
        retryDelaySeconds:
          type: integer
        created_at:
          type: string
          format: date-time

    CreateChannelRequest:
      type: object
      required:
        - name
        - channelType
        - config
      properties:
        name:
          type: string
        channelType:
          $ref: '#/components/schemas/NotificationChannelType'
        config:
          type: object
          description: Channel-specific configuration (encrypted at rest)
        rateLimitPerMinute:
          type: integer
        rateLimitBurst:
          type: integer
        maxRetries:
          type: integer
        retryDelaySeconds:
          type: integer

    SendNotificationRequest:
      type: object
      required:
        - channelId
        - recipient
      properties:
        channelId:
          type: string
          format: uuid
        templateId:
          type: string
          format: uuid
        recipient:
          type: string
        subject:
          type: string
        body:
          type: string
        htmlBody:
          type: string
        variables:
          type: object
        priority:
          $ref: '#/components/schemas/NotificationPriority'
        scheduledAt:
          type: string
          format: date-time
        correlationId:
          type: string
        metadata:
          type: object

    NotificationSendResult:
      type: object
      properties:
        queued:
          type: boolean
        notificationId:
          type: string
          format: uuid
        estimatedDelivery:
          type: string
          format: date-time

    NotificationHistoryEntry:
      type: object
      properties:
        id:
          type: string
          format: uuid
        channel_id:
          type: string
          format: uuid
        template_id:
          type: string
          format: uuid
        recipient:
          type: string
        status:
          type: string
          enum: [pending, queued, sending, sent, delivered, failed, retrying]
        sent_at:
          type: string
          format: date-time
        delivered_at:
          type: string
          format: date-time
        error_message:
          type: string

    NotificationAnalytics:
      type: object
      properties:
        total:
          type: integer
        byStatus:
          type: object
        byChannel:
          type: object
        averageDeliveryTimeMs:
          type: number
        failureRate:
          type: number

    WebhookEventType:
      type: string
      enum:
        - '*'
        - mission.created
        - mission.started
        - mission.completed
        - mission.failed
        - mission.cancelled
        - stage.started
        - stage.completed
        - stage.failed
        - artifact.created
        - artifact.verified
        - audit.event

    WebhookSignatureAlgorithm:
      type: string
      enum: [hmac-sha256, hmac-sha512]

    WebhookSubscription:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        description:
          type: string
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        active:
          type: boolean
        signature_algorithm:
          $ref: '#/components/schemas/WebhookSignatureAlgorithm'
        timeout_ms:
          type: integer
        max_retries:
          type: integer
        rate_limit_rps:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateWebhookRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        description:
          type: string
          maxLength: 255
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        signature_algorithm:
          $ref: '#/components/schemas/WebhookSignatureAlgorithm'
        timeout_ms:
          type: integer
          minimum: 1000
          maximum: 60000
          default: 30000
        max_retries:
          type: integer
          minimum: 0
          maximum: 10
          default: 3
        rate_limit_rps:
          type: integer
          minimum: 1
          maximum: 100
          default: 10
        skip_validation:
          type: boolean
          default: false

    UpdateWebhookRequest:
      type: object
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        description:
          type: string
          maxLength: 255
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        active:
          type: boolean
        timeout_ms:
          type: integer
          minimum: 1000
          maximum: 60000
        max_retries:
          type: integer
          minimum: 0
          maximum: 10
        rate_limit_rps:
          type: integer
          minimum: 1
          maximum: 100

    WebhookDelivery:
      type: object
      properties:
        id:
          type: string
          format: uuid
        subscription_id:
          type: string
          format: uuid
        event_type:
          $ref: '#/components/schemas/WebhookEventType'
        payload:
          type: object
        status:
          type: string
          enum: [pending, delivered, failed, retrying]
        http_status:
          type: integer
        response_body:
          type: string
        error_message:
          type: string
        attempt_count:
          type: integer
        created_at:
          type: string
          format: date-time
        delivered_at:
          type: string
          format: date-time

    WebhookAnalytics:
      type: object
      properties:
        total_deliveries:
          type: integer
        successful:
          type: integer
        failed:
          type: integer
        retrying:
          type: integer
        success_rate:
          type: number
        average_latency_ms:
          type: number
        by_event_type:
          type: object

    WorkspaceAnalytics:
      type: object
      description: |
        Workspace-level analytics for a given time period.
        `total_cost_usd` is sourced from `workspace_usage.total_llm_cost_usd`,
        which is written by `trackTokens()` using per-model pricing at call time.
        It is **not** a rough per-token estimate.
      properties:
        summary:
          type: object
          properties:
            missions_total:
              type: integer
            missions_completed:
              type: integer
            missions_failed:
              type: integer
            success_rate:
              type: number
              description: Percentage of missions that completed successfully (0-100)
            total_tokens:
              type: integer
              description: Total LLM tokens consumed across all agent_runs in the period
            total_cost_usd:
              type: number
              description: |
                Real accumulated LLM cost in USD for the period, sourced from
                workspace_usage.total_llm_cost_usd (written by trackTokens at
                per-model pricing). Returns 0 when no usage rows exist for the period.
            avg_duration_seconds:
              type: integer
              description: Average mission wall-clock duration in seconds
        daily:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
                format: date
              missions:
                type: integer
              completed:
                type: integer
              failed:
                type: integer
              tokens:
                type: integer
        agent_distribution:
          type: array
          items:
            type: object
            properties:
              role:
                type: string
              count:
                type: integer
        period:
          type: string
          description: The period string used for the query (e.g. "30d")

    UsageCostAware:
      type: object
      description: |
        The token/call/cost quartet shared by the per-model, per-role and totals views.
        `costUsd` is the sum over rows with a resolved price ONLY; `costUnknownCalls` is how
        many calls in the same slice had no resolvable price and were therefore excluded.
      required: [promptTokens, completionTokens, tokens, calls, costUsd, costUnknownCalls]
      properties:
        promptTokens:
          type: integer
          description: Raw prompt token count (not thousands)
        completionTokens:
          type: integer
          description: Raw completion token count (not thousands)
        tokens:
          type: integer
          description: promptTokens + completionTokens, raw
        calls:
          type: integer
          description: Ledger rows (provider attempts, success and failure) in this slice
        costUsd:
          type: number
          description: |
            Sum of cost_usd over rows where it IS NOT NULL. Never a coalesced NULL. Read it
            together with costUnknownCalls before rendering money.
        costUnknownCalls:
          type: integer
          description: Calls whose price could not be resolved and are excluded from costUsd

    UsageMissionRoleSegment:
      type: object
      required: [role, tokens]
      properties:
        role:
          type: string
          description: Agent role, or "unattributed" for ledger rows with no agent_role
        tokens:
          type: integer

    UsageMissionBreakdown:
      type: object
      required:
        [missionId, goal, tokens, calls, costUsd, costUnknownCalls, roleCount, roleSegments,
         prevTokens, deltaPct]
      properties:
        missionId:
          type: string
          format: uuid
        goal:
          type: string
          nullable: true
          description: missions.goal free text; null only if the mission row could not be read
        tokens:
          type: integer
        calls:
          type: integer
          description: >-
            Ledger rows in this mission. Paired with costUnknownCalls it makes cost
            COVERAGE derivable for a mission focus, exactly as it already is for a
            model or role row. costUnknownCalls alone cannot distinguish a partially
            priced mission from a fully unpriced one.
        costUsd:
          type: number
          description: Priced rows only — see UsageCostAware.costUsd
        costUnknownCalls:
          type: integer
        roleCount:
          type: integer
          description: Distinct roles that spent tokens on this mission in the window
        roleSegments:
          type: array
          description: Per-role split of this mission's tokens; sums to tokens
          items:
            $ref: '#/components/schemas/UsageMissionRoleSegment'
        prevTokens:
          type: integer
          description: |
            The same mission's tokens in the immediately preceding window of equal length.
        deltaPct:
          type: number
          nullable: true
          description: |
            Percentage change of tokens against prevTokens. **null when prevTokens is 0** —
            growth from a zero baseline is undefined, and reporting it as 0% would be a
            fabricated number.

    UsageLedgerResidency:
      type: object
      description: |
        Which regions actually served this workspace's LLM calls in the window, with the
        provenance of each answer. See the endpoint description for what dispatch region does
        and does not prove.
      required: [windowDays, startIso, endIso, regions, notObservedCalls, totalCalls]
      properties:
        windowDays:
          type: integer
          description: Window length in whole days. The window is windowDays x 24h ending now.
        startIso:
          type: string
          format: date-time
        endIso:
          type: string
          format: date-time
        regions:
          type: array
          description: |
            Every DISTINCT observed dispatch region in the window, most calls first. A region
            with zero calls does not appear — absence, not a fabricated zero.
          items:
            type: object
            required: [region, calls, wireEndpointCalls, clientConfigCalls, euResidency]
            properties:
              region:
                type: string
                description: AWS region the calls were dispatched to, e.g. eu-central-1.
              calls:
                type: integer
              wireEndpointCalls:
                type: integer
                description: |
                  Calls whose region was read from the endpoint hostname the SDK actually
                  contacted — the strongest per-call evidence available.
              clientConfigCalls:
                type: integer
                description: |
                  Calls whose region came from the invoking client's own resolved config,
                  used when the wire endpoint was not readable. Weaker evidence, reported
                  separately rather than merged into wireEndpointCalls.
              euResidency:
                type: boolean
                description: |
                  True iff the region is in the EU-member-state allowlist. eu-west-2 (London,
                  UK) and eu-central-2 (Zurich, CH) are false despite the eu- prefix.
        notObservedCalls:
          type: integer
          description: |
            Calls in the window with NO observed region (a transport with no AWS region, or an
            attempt that failed before dispatch). Disclosed rather than folded into a region —
            this is the part of the window the residency answer does not cover.
        totalCalls:
          type: integer
          description: |
            All ledger rows in the window. Always equals notObservedCalls plus the sum of
            regions[].calls.

    UsageBreakdown:
      type: object
      description: |
        Usage aggregates for one window, sourced solely from llm_call_ledger. See the endpoint
        description for the cost-honesty, unattributed-spend and Sankey conservation rules.
      required: [window, totals, models, roles, missions, flows, daily, hourly, generatedAtIso]
      properties:
        window:
          type: object
          required: [key, days, startIso, endIso]
          properties:
            key:
              type: string
              enum: ['24h', '7d', '30d']
            days:
              type: integer
              description: Window length in whole days (24h = 1). The window is days x 24h.
            startIso:
              type: string
              format: date-time
            endIso:
              type: string
              format: date-time
        totals:
          allOf:
            - $ref: '#/components/schemas/UsageCostAware'
            - type: object
              required:
                [callsToday, costKnownCalls, missionsInRange, peakHourUtc, unattributedTokens,
                 unattributedCalls]
              properties:
                callsToday:
                  type: integer
                  description: Calls since 00:00 UTC today (a sub-slice of the window)
                costKnownCalls:
                  type: integer
                  description: |
                    Calls that DID have a resolvable price. 0 here means costUsd carries no
                    information and the client must render an em dash, not $0.00.
                missionsInRange:
                  type: integer
                  description: Distinct missions with ledger activity (NULL mission_id excluded)
                peakHourUtc:
                  type: integer
                  nullable: true
                  minimum: 0
                  maximum: 23
                  description: |
                    UTC hour-of-day with the most tokens. **null when the window holds no token
                    data at all** — never a defaulted hour 0.
                unattributedTokens:
                  type: integer
                  description: Tokens on rows with no mission OR no role
                unattributedCalls:
                  type: integer
        models:
          type: array
          description: Per-model slice, tokens-descending. Keyed by model id.
          items:
            allOf:
              - $ref: '#/components/schemas/UsageCostAware'
              - type: object
                required: [model, provider]
                properties:
                  model:
                    type: string
                  provider:
                    type: string
                    description: |
                      Provider(s) that actually served this model id in the window. Almost
                      always one; if several, they are listed sorted and "+"-joined rather than
                      picking a winner. The Sankey's model node is keyed by model alone.
        roles:
          type: array
          description: Per-role slice, tokens-descending. NULL agent_role folds to "unattributed".
          items:
            allOf:
              - $ref: '#/components/schemas/UsageCostAware'
              - type: object
                required: [role, series]
                properties:
                  role:
                    type: string
                  series:
                    type: array
                    description: |
                      One bucket per day in the window, oldest first — always exactly
                      window.days entries, summing to this role's tokens. Every bucket inside
                      the window was observed, so a 0 here means "observed, zero tokens".
                    items:
                      type: integer
        missions:
          type: array
          maxItems: 10
          description: |
            Top 10 missions by tokens. The "unattributed" missionKey never appears here — it is
            not a mission — but its tokens are still in totals and in flows.missionRole.
          items:
            $ref: '#/components/schemas/UsageMissionBreakdown'
        flows:
          type: object
          description: |
            The Sankey edge lists. Both are folded from the same single joint aggregate as
            models/roles/missions, so all four marginal sums balance exactly (see the endpoint
            description). Ribbon widths can be laid out without clamping.
          required: [missionRole, roleModel]
          properties:
            missionRole:
              type: array
              description: |
                mission -> role edges. Carries EVERY mission key in the window (not just the
                ten in missions[]) plus the "unattributed" key, because truncating it would make
                the per-role column sums disagree with roles[].tokens.
              items:
                type: object
                required: [missionKey, role, tokens]
                properties:
                  missionKey:
                    type: string
                    description: A mission UUID, or the literal "unattributed"
                  role:
                    type: string
                  tokens:
                    type: integer
            roleModel:
              type: array
              description: role -> model edges.
              items:
                type: object
                required: [role, model, tokens]
                properties:
                  role:
                    type: string
                  model:
                    type: string
                  tokens:
                    type: integer
        daily:
          type: array
          description: |
            One entry per day in the window, oldest first (exactly window.days entries). Buckets
            are exactly 24h wide and anchored on the window start.
          items:
            type: object
            required: [dateIso, byModel]
            properties:
              dateIso:
                type: string
                format: date
                description: UTC date of this bucket's start instant
              byModel:
                type: object
                description: |
                  Tokens per model for this bucket. Sparse on purpose — a model ABSENT from the
                  map made no calls that day; that is honest absence, not a zero.
                additionalProperties:
                  type: integer
        hourly:
          type: array
          description: |
            Hour-of-week heatmap cells. Only cells the ledger actually has rows for are emitted:
            for a 24h window most (dow, hour) cells are not covered by the window at all, and
            emitting them as 0 would assert "zero usage" for a period that was never observed.
          items:
            type: object
            required: [dow, hour, tokens]
            properties:
              dow:
                type: integer
                minimum: 0
                maximum: 6
                description: Day of week, UTC — 0 = Monday .. 6 = Sunday
              hour:
                type: integer
                minimum: 0
                maximum: 23
                description: Hour of day, UTC
              tokens:
                type: integer
        generatedAtIso:
          type: string
          format: date-time

    ApprovalRequestSummary:
      type: object
      description: |
        Summary row for the approval queue list (GET /approval-requests).
        `approvals_so_far` is computed server-side from the real
        `context.approvers` jsonb array — never fabricated.
      properties:
        id:
          type: string
          format: uuid
        mission_id:
          type: string
          format: uuid
        type:
          type: string
          enum: [plan_approval, execution_approval, delivery_approval, budget_override, tool_escalation]
        status:
          type: string
          enum: [pending, approved, rejected, expired, auto_approved]
        priority:
          type: string
          enum: [low, normal, high, critical]
        approval_tier:
          type: integer
          nullable: true
          description: Risk tier 1-4; null on legacy rows (treated as tier 1)
        dual_approver_required:
          type: boolean
          description: TIER_4 requests require two distinct human approvers
        requested_at:
          type: string
          format: date-time
        decided_at:
          type: string
          format: date-time
          nullable: true
        decision:
          type: string
          nullable: true
        approvals_so_far:
          type: integer
          description: COALESCE(jsonb_array_length(context->'approvers'), 0)

    ApprovalRequestDetail:
      type: object
      description: Full row detail for a single approval request (GET /approval-requests/{approvalId}).
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        mission_id:
          type: string
          format: uuid
        stage_id:
          type: string
          format: uuid
          nullable: true
        task_id:
          type: string
          format: uuid
          nullable: true
        type:
          type: string
          enum: [plan_approval, execution_approval, delivery_approval, budget_override, tool_escalation]
        status:
          type: string
          enum: [pending, approved, rejected, expired, auto_approved]
        priority:
          type: string
          enum: [low, normal, high, critical]
        approval_tier:
          type: integer
          nullable: true
        dual_approver_required:
          type: boolean
        expires_at:
          type: string
          format: date-time
        context:
          type: object
          description: |
            Approval context JSONB. For TIER_4 dual-approval requests,
            `context.approvers` is a jsonb array of user-id strings.
        requested_at:
          type: string
          format: date-time
        decided_by:
          type: string
          format: uuid
          nullable: true
        decided_at:
          type: string
          format: date-time
          nullable: true
        decision:
          type: string
          nullable: true
        decision_notes:
          type: string
          nullable: true
        approvals_so_far:
          type: integer
          description: COALESCE(jsonb_array_length(context->'approvers'), 0)

    ApprovalDecisionSummary:
      type: object
      description: >-
        TIER_4 deciding-vote response body: the approval flipped to
        `approved` with the full distinct-approver list.
      required: [id, status, approvers]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [approved]
        approvers:
          type: array
          minItems: 2
          items:
            type: string
          description: Distinct user IDs that approved, in vote order

    DecisionWebauthnAssertion:
      type: object
      description: >-
        A WebAuthn assertion made with an already-registered login passkey
        over a challenge issued by the `webauthn-challenge` endpoint for this
        exact approval and decision. The verified ceremony artifacts are
        stored in the same transaction as the decision they evidence.
      required: [challengeId, response]
      properties:
        challengeId:
          type: string
          format: uuid
          description: >-
            The `challengeId` returned by the challenge endpoint. Consumed
            atomically — a second use of the same challenge is refused.
        response:
          type: object
          description: >-
            The authenticator's assertion, in the shape produced by the
            browser credential API.
          required: [id, response]
          properties:
            id:
              type: string
              description: Base64url credential id of the passkey that signed.
            rawId:
              type: string
            type:
              type: string
              enum: [public-key]
            response:
              type: object
              required: [clientDataJSON, authenticatorData, signature]
              properties:
                clientDataJSON:
                  type: string
                authenticatorData:
                  type: string
                signature:
                  type: string

    DualApprovalFirstVoteResponse:
      type: object
      description: TIER_4 first-vote 202 body — one more distinct approver required.
      required: [message, approvalId, approversSoFar, requiredApprovers]
      properties:
        message:
          type: string
        approvalId:
          type: string
          format: uuid
        approversSoFar:
          type: integer
        requiredApprovers:
          type: integer
          enum: [2]

    PlanHoldConfigPayload:
      type: object
      description: |
        Wire shape of `workspace_policies.plan_approval_hold` — exactly what
        the runtime resolver reads. All six fields are required; unknown
        fields are refused.
      required: [enabled, critical_only, reminder_seconds, escalation_seconds, expiry_seconds, expiry_action]
      properties:
        enabled:
          type: boolean
          description: Arms the hold. Production default is false (nothing holds).
        critical_only:
          type: boolean
          description: When true, only critical-class missions hold; when false, every mission holds.
        reminder_seconds:
          type: integer
          minimum: 1
          description: Seconds after hold creation before a reminder fires. Must be < escalation_seconds.
        escalation_seconds:
          type: integer
          minimum: 1
          description: Seconds before escalation. Must be strictly between reminder and expiry.
        expiry_seconds:
          type: integer
          minimum: 1
          description: Seconds before the hold expires. Must be > escalation_seconds.
        expiry_action:
          type: string
          enum: [cancel, fail]
          description: What expiry does. Never approve — expiry can only stop, not proceed.

    PlanHoldEffectiveConfig:
      type: object
      description: The config the runtime applies (resolver output, camelCase).
      properties:
        enabled:
          type: boolean
        criticalOnly:
          type: boolean
        reminderSeconds:
          type: integer
        escalationSeconds:
          type: integer
        expirySeconds:
          type: integer
        expiryAction:
          type: string
          enum: [cancel, fail]

    OversightPolicyContent:
      type: object
      description: |
        The oversight-policy content document. Content is
        canonicalised (sorted keys at every depth) and SHA-256 hashed to give
        the version its content-addressed identity, so every field below is
        part of that identity. Unknown keys are rejected with 400. Version 1
        is a DESCRIPTOR of the behaviour already in force — it switches
        nothing on.
      required:
        - ladder_source
        - tier4_notes_required
        - tier2_sampling
        - webauthn
        - dynamic_escalation
        - approver_groups
        - requester_excluded
      properties:
        ladder_source:
          type: string
          minLength: 1
          maxLength: 500
          description: Where the approval-hold ladder timings come from (descriptive, not a switch).
        tier4_notes_required:
          type: boolean
          description: Whether a Tier-4 decision must carry approver notes. Disclosure-only today.
        tier2_sampling:
          $ref: '#/components/schemas/OversightPolicyToggle'
        webauthn:
          type: string
          minLength: 1
          maxLength: 100
          description: WebAuthn step-up posture for oversight decisions.
        dynamic_escalation:
          $ref: '#/components/schemas/OversightPolicyToggle'
        approver_groups:
          $ref: '#/components/schemas/OversightPolicyToggle'
        requester_excluded:
          type: boolean
          description: Whether the requester is excluded from approving their own request.

    OversightPolicyToggle:
      type: object
      description: A nested policy switch; `enabled` is its only permitted key.
      required: [enabled]
      properties:
        enabled:
          type: boolean

    ActiveOversightPolicy:
      type: object
      description: |
        The active policy version. `version: 0` means the workspace has no
        `workspace_oversight_policies` row and is being served the static
        implicit-defaults descriptor — an honest fallback, not a stored row.
      properties:
        version:
          type: integer
          description: 0 for the implicit-defaults fallback, 1+ for a stored version row.
        hash:
          type: string
          description: SHA-256 over the canonical JSON of `content`.
        content:
          $ref: '#/components/schemas/OversightPolicyContent'

    PendingPolicyChange:
      type: object
      nullable: true
      description: The single live `policy_change` proposal, or null when none is pending.
      properties:
        approvalId:
          type: string
          format: uuid
        requestedAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
          description: 72h after proposal. An expired proposal can never be applied.
        approvalsSoFar:
          type: integer
          description: Distinct humans who have voted to approve so far (2 are required).
        diff:
          nullable: true
          description: Per-key old/new diff between the active content and the proposal.
        proposedHash:
          type: string
          nullable: true
          description: SHA-256 over the canonical JSON of the proposed content.

    PlanPreviewResponse:
      type: object
      description: >-
        The plan preview. Every block is computed from a real source; a block
        that could not be computed is null rather than filled in.
      required: [classification, corpus, plan, plan_error, plan_source, routing, governance, catalog]
      properties:
        corpus:
          $ref: '#/components/schemas/PlanPreviewCorpus'
        classification:
          type: object
          description: >-
            Produced by a single builder shared with POST /missions/from-objective, so the
            number and the template name beside it always come from the same producer.
          properties:
            template:
              type: string
              description: The chosen mission template id.
            confidence:
              type: number
              nullable: true
              description: >-
                The keyword classifier's score for `template`, and NULL whenever the keyword
                classifier did not choose it — the intent extractor emits no per-template score,
                so there is no number to show and none may be borrowed from elsewhere.
            source:
              type: string
              enum: [llm_intent, keyword]
              description: >-
                Who chose `template`. `llm_intent` when the LLM intent extractor answered
                (flag AX_DYNAMIC_PLANS), `keyword` for the deterministic classifier.
            signal:
              type: string
              enum: [matched, no_signal, not_measured]
              description: >-
                What the selecting component actually had. `no_signal` is NOT a low
                confidence — it means the classifier matched nothing, which is a different fact
                from "measured, and the answer is low". `not_measured` accompanies a null
                confidence.
            matched_keywords:
              type: integer
              nullable: true
              description: Evidence count for `template`, or null when this surface measured none.
            matched_patterns:
              type: integer
              nullable: true
            matched_semantic_indicators:
              type: integer
              nullable: true
            ambiguity_score:
              type: number
              nullable: true
              description: >-
                The keyword ranking's own ambiguity, null when that ranking did not decide
                the template.
        plan:
          nullable: true
          description: >-
            The planner dry-run result, or null when the dry-run failed (see
            plan_error).
          allOf:
            - $ref: '#/components/schemas/PlanPreviewPlan'
        plan_error:
          type: string
          nullable: true
          description: Why no plan was produced. Null when `plan` is present.
        plan_source:
          type: string
          enum: [planner_dry_run, template_prior]
          description: >-
            Where the proposed roles came from. `template_prior` whenever `plan`
            is null — the roles then come from the classified template, not from
            a plan.
        preview_id:
          type: string
          format: uuid
          description: >-
            Correlation id of this preview's own LLM calls (planner dry run,
            intent extraction). Send it back on /missions/from-objective to
            attribute that spend to the mission it launches.
        plan_binding:
          type: string
          enum: [illustrative]
          description: >-
            The plan does not bind the run: the mission plans itself again at
            launch, so roles, task counts and the serving models can differ.
        plan_binding_note:
          type: string
        routing:
          nullable: true
          description: >-
            Per role, the model the mission's own selection gives its planned
            tasks, plus the settled-history estimate; null when there is no
            role to show.
          allOf:
            - $ref: '#/components/schemas/PlanPreviewRouting'
        governance:
          $ref: '#/components/schemas/PlanPreviewGovernance'
        catalog:
          $ref: '#/components/schemas/PlanPreviewCatalog'

    PlanPreviewPlan:
      type: object
      properties:
        agents:
          type: array
          items:
            type: object
            properties:
              role:
                type: string
              specialization:
                type: string
                nullable: true
              lifecycle:
                type: string
                nullable: true
                description: PERSISTENT or EPHEMERAL, as the planner declared it.
        task_count:
          type: integer
        model_used:
          type: string
          nullable: true
        latency_ms:
          type: integer
          nullable: true
        quality_score:
          type: number
          nullable: true

    PlanPreviewRouting:
      type: object
      properties:
        chosen_roles:
          type: array
          items:
            type: string
        per_role:
          type: array
          items:
            type: object
            properties:
              role:
                type: string
              provider:
                type: string
              model_id:
                type: string
              downgraded:
                type: boolean
                description: True when the policy engine skipped the first candidate of a task's ladder.
              region:
                type: string
                nullable: true
                enum: [eu]
              enabled:
                type: boolean
                description: >-
                  Always true: only a model the serving catalog enables is
                  listed. A role whose tasks resolve to no servable model is in
                  `dropped_roles` with the reason instead.
              selection:
                type: string
                enum: [policy_engine_v1, task_executor_automatic]
                description: Which of the mission's own selections produced the model.
              task_count:
                type: integer
                description: Planned tasks of this role that resolved to a servable model.
              models:
                type: array
                description: Every model the role's planned tasks resolve to.
                items:
                  type: object
                  properties:
                    provider:
                      type: string
                    model_id:
                      type: string
                    task_count:
                      type: integer
        estimated_cost_usd:
          type: number
          nullable: true
          description: >-
            Median settled cost of this workspace's completed missions on the
            same template (and triage tier, when triage resolved one) over
            `estimate_scope.window_days`, rounded to cents. Null when there is
            no settled history — never a per-token guess.
        estimate_basis:
          type: string
          enum: [workspace_history_median, no_history]
        sample_size:
          type: integer
          description: Settled missions the median was taken over (0 with no_history).
        estimate_scope:
          type: object
          properties:
            template:
              type: string
            tier:
              type: string
              nullable: true
            window_days:
              type: integer
        budget_usd:
          type: number
          nullable: true
        within_budget:
          type: boolean
          nullable: true
          description: Null when there is no estimate to compare against the budget.
        rationale:
          type: array
          items:
            type: string
        warnings:
          type: array
          items:
            type: string
        dropped_roles:
          type: array
          items:
            type: object
            properties:
              role:
                type: string
              reason:
                type: string

    PlanPreviewCorpus:
      type: object
      description: >-
        Which documents the plan was drafted with — and so which ones a launch
        sending the same choice reads.
      required: [selection, data_workspace_id, document_count, drafted_with_corpus, note]
      properties:
        selection:
          type: string
          enum: [explicit, none, unspecified]
          description: >-
            `explicit`: a corpus was named. `none`: data_workspace_id was
            null — no documents. `unspecified`: the field was absent.
        data_workspace_id:
          type: string
          format: uuid
          nullable: true
        document_count:
          type: integer
          nullable: true
          description: >-
            The documents in the named corpus as listed for the dry run. Null
            when no corpus is named, and null when the list could not be read
            (the note says which).
        drafted_with_corpus:
          type: boolean
          description: True exactly when the planner dry run was told a corpus is attached.
        note:
          type: string

    PlanPreviewGovernance:
      type: object
      properties:
        risk_class:
          type: string
          enum: [low, medium, high, critical]
        plan_hold:
          type: object
          properties:
            enabled:
              type: boolean
              description: The RESOLVED hold config's enabled flag (not raw JSONB truthiness).
            critical_only:
              type: boolean
              description: >-
                The stored `critical_only` flag. False on the platform default,
                which holds high-risk missions too; `hold_risk_classes` says
                which classes hold.
            would_hold_this_mission:
              type: boolean
              description: >-
                The planner's own predicate applied to this objective's risk
                class. Never a claim the planner would not honour.
            source:
              type: string
              enum: [workspace_config, platform_default]
              description: >-
                `platform_default` when the workspace stores no plan-hold
                setting: armed for high- and critical-risk missions.
            hold_risk_classes:
              type: array
              items:
                type: string
                enum: [low, medium, high, critical]
              description: The risk classes that hold while the hold is enabled.
        launch_gate_armed:
          type: boolean
          description: >-
            LAUNCH_GATE_ENABLED — one switch for the whole deployment, not a
            workspace setting. Ships dark (false) by default.
        egress:
          $ref: '#/components/schemas/PlanPreviewEgress'
        note:
          type: string
          description: One honest sentence derived from the fields above.

    PlanPreviewEgress:
      type: object
      description: >-
        The egress allowlist the mission's http_request calls are classified
        against, from the same resolver the tool loop uses. A count only; the
        hosts are never returned.
      properties:
        state:
          type: string
          enum: [configured, empty]
          description: >-
            `empty` means every external fetch (http_request) halts for human
            approval.
        allowlisted_hosts:
          type: integer
        source:
          type: string
          enum: [env, policy, EMPTY-DEGRADED]
        note:
          type: string

    PlanPreviewCatalog:
      type: object
      properties:
        servable:
          type: array
          description: The enabled catalog entries — the pool routing may propose today.
          items:
            type: object
            properties:
              provider:
                type: string
              model_id:
                type: string
              region:
                type: string
                nullable: true
                enum: [eu]
              cost_in_per_1m:
                type: number
                nullable: true
                description: Null when the catalog cannot price the pair — never a fabricated zero.
              cost_out_per_1m:
                type: number
                nullable: true
        parked:
          type: array
          description: Catalogued but not enabled for serving.
          items:
            type: object
            properties:
              provider:
                type: string
              model_id:
                type: string
              reason:
                type: string
                description: >-
                  Excerpted from the entry's own availability evidence, or an
                  explicit statement that no evidence is recorded.
        candidates:
          type: array
          description: >-
            Priced pool candidates in the non-dispatchable candidate namespace.
          items:
            type: object
            properties:
              slug:
                type: string
                description: Catalog identity, deliberately NOT a wire model id.
              display_name:
                type: string
              note:
                type: string

    ApprovedModelPlanRequest:
      type: object
      description: >-
        Per-role ordered model preference approved by an operator. Validated
        server-side before the mission is created.
      required: [agents]
      properties:
        agents:
          type: array
          minItems: 1
          maxItems: 32
          items:
            type: object
            required: [role, models]
            properties:
              role:
                type: string
                minLength: 1
                maxLength: 64
                description: >-
                  Agent role. Must be classifiable to a task type, so a
                  capability floor can be applied to it.
              models:
                type: array
                minItems: 1
                maxItems: 8
                description: >-
                  Ordered preference, most preferred first. Catalog model ids,
                  optionally provider-qualified as `provider:modelId`. The pin
                  resolves to the first SERVABLE entry; later non-servable
                  entries are recorded and skipped at dispatch.
                items:
                  type: string
                  minLength: 1
                  maxLength: 160

    InvalidModelPlanResponse:
      type: object
      properties:
        error:
          type: string
          enum: [InvalidModelPlan]
        message:
          type: string
        code:
          type: string
          enum:
            - unknown_role
            - unknown_model
            - duplicate_role
            - no_servable_entry
            - capability_floor_unmet
            - model_disabled_by_workspace_control
            - provider_disabled_by_workspace_control
        role:
          type: string
        model_id:
          type: string

    GatewayModelsResponse:
      type: object
      properties:
        generated_at:
          type: string
          format: date-time
        source:
          type: string
          enum: [runtime_adapters]
        providers:
          type: array
          items:
            type: object
            properties:
              provider:
                type: string
              transport:
                type: string
              configured:
                type: boolean
              default_model_id:
                type: string
                nullable: true
              health:
                $ref: '#/components/schemas/GatewayProviderHealth'
              models:
                type: array
                items:
                  type: object
                  properties:
                    provider:
                      type: string
                    model_id:
                      type: string
                    display_name:
                      type: string
                    cost_per_1k_input_tokens:
                      type: number
                    cost_per_1k_output_tokens:
                      type: number

    GatewayProviderHealth:
      type: object
      properties:
        available:
          type: boolean
        latency_ms:
          type: number
          nullable: true
        last_checked:
          type: string
          nullable: true
        error_rate:
          type: number
          nullable: true
        consecutive_failures:
          type: integer
          nullable: true
        circuit:
          type: string
          enum: [closed, open, half-open]
          description: >-
            Provider circuit-breaker state from the gateway health tracker.
            `open` means the breaker tripped and routing is skipping this
            provider; `half-open` means the cooldown elapsed and one test
            request is allowed.

    ApprovalQueueResponse:
      type: object
      properties:
        pending:
          type: array
          description: Pending approval requests, oldest / most-overdue first (limit 100)
          items:
            $ref: '#/components/schemas/ApprovalRequestSummary'
        recent:
          type: array
          description: Decided approval requests, most recently decided first (limit 50)
          items:
            $ref: '#/components/schemas/ApprovalRequestSummary'

    AuditEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        mission_id:
          type: string
          format: uuid
          nullable: true
        actor_type:
          type: string
          enum: [user, agent, system, worker]
        actor_id:
          type: string
        action:
          type: string
        resource_type:
          type: string
        resource_id:
          type: string
        outcome:
          type: string
          enum: [success, failure, denied, error]
        metadata:
          type: object
        ip_address:
          type: string
        user_agent:
          type: string
        hash:
          type: string
        seq:
          type: integer
        created_at:
          type: string
          format: date-time

    AuditChainVerification:
      type: object
      properties:
        status:
          type: string
          enum: [ok, tampered, partial]
        verified_at:
          type: string
          format: date-time
        head_hash:
          type: string
        counts:
          type: object
          properties:
            total:
              type: integer
            verified:
              type: integer
            failed:
              type: integer
        first_bad_seq:
          type: integer
          nullable: true
        first_bad_event_id:
          type: string
          format: uuid
          nullable: true
        missing_seq:
          type: array
          items:
            type: integer
        reasons:
          type: array
          items:
            type: string
        key_fingerprint:
          type: string

    Integration:
      type: object
      properties:
        id:
          type: string
          format: uuid
        integration_type:
          type: string
        name:
          type: string
        status:
          type: string
          enum: [active, inactive, error]
        config:
          type: object
        created_at:
          type: string
          format: date-time

    RegisterGitHubRequest:
      type: object
      properties:
        name:
          type: string
          default: GitHub
        credentials_ref:
          type: string
          description: 'Required. The GitHub token value itself; indirect references (env:, vault:, ...) are rejected with 400 ValidationError.'

    Template:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        version:
          type: string
        capabilities:
          type: array
          items:
            type: string
        parameters:
          type: object

    HealthStatus:
      type: object
      properties:
        status:
          type: string
          enum: [healthy, degraded, unhealthy]
        version:
          type: string
        timestamp:
          type: string
          format: date-time
        checks:
          type: object

    BulkOperationType:
      type: string
      enum:
        - missions.cancel
        - missions.delete
        - missions.archive
        - artifacts.delete
        - artifacts.export
        - notifications.mark_read
        - notifications.delete
        - notifications.archive

    BulkOperationStatus:
      type: string
      enum:
        - pending
        - queued
        - processing
        - completed
        - partial
        - failed
        - cancelled

    BulkOperationRequest:
      type: object
      required:
        - operation
      properties:
        operation:
          $ref: '#/components/schemas/BulkOperationType'
        ids:
          type: array
          items:
            type: string
            format: uuid
          maxItems: 1000
          description: Specific IDs to process (if empty, uses filters)
        filters:
          $ref: '#/components/schemas/BulkOperationFilters'
        limit:
          type: integer
          minimum: 1
          maximum: 1000
          default: 1000
          description: Maximum items to process
        dryRun:
          type: boolean
          default: false
          description: Validate without executing
        options:
          type: object
          description: Operation-specific options

    BulkOperationFilters:
      type: object
      properties:
        dateRange:
          type: object
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
        status:
          type: array
          items:
            type: string
        type:
          type: array
          items:
            type: string
        missionIds:
          type: array
          items:
            type: string
            format: uuid
        createdBy:
          type: array
          items:
            type: string
            format: uuid
        metadata:
          type: object

    BulkOperationResponse:
      type: object
      properties:
        operationId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/BulkOperationStatus'
        total:
          type: integer
          description: Total items to process
        processed:
          type: integer
          description: Items processed so far
        succeeded:
          type: integer
          description: Items that succeeded
        failed:
          type: integer
          description: Items that failed
        estimatedCompletion:
          type: string
          format: date-time
        error:
          type: string

    BulkOperationValidation:
      type: object
      properties:
        valid:
          type: boolean
        affectedCount:
          type: integer
          description: Number of items that would be affected
        errors:
          type: array
          items:
            type: string
        warnings:
          type: array
          items:
            type: string
        preview:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
              status:
                type: string

    BulkOperationStatusDetail:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workspaceId:
          type: string
          format: uuid
        initiatedBy:
          type: string
          format: uuid
        operation:
          $ref: '#/components/schemas/BulkOperationType'
        status:
          $ref: '#/components/schemas/BulkOperationStatus'
        total:
          type: integer
        processed:
          type: integer
        succeeded:
          type: integer
        failed:
          type: integer
        failures:
          type: array
          items:
            $ref: '#/components/schemas/BulkOperationFailure'
        createdAt:
          type: string
          format: date-time
        startedAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
        dryRun:
          type: boolean

    BulkOperationFailure:
      type: object
      properties:
        id:
          type: string
        code:
          type: string
        message:
          type: string
        retryable:
          type: boolean

    BulkMissionsRequest:
      type: object
      properties:
        ids:
          type: array
          items:
            type: string
            format: uuid
        filters:
          type: object
          properties:
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time
            status:
              type: array
              items:
                type: string

    BulkArtifactsRequest:
      type: object
      properties:
        ids:
          type: array
          items:
            type: string
            format: uuid
        filters:
          type: object
          properties:
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time
            types:
              type: array
              items:
                type: string

    BulkNotificationsRequest:
      type: object
      properties:
        ids:
          type: array
          items:
            type: string
            format: uuid
        filters:
          type: object
          properties:
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time
            status:
              type: array
              items:
                type: string

    WebSocketMessage:
      type: object
      required:
        - type
        - timestamp
      properties:
        type:
          type: string
          description: Message type
          enum: [subscribe, unsubscribe, ping, event, error]
        timestamp:
          type: string
          format: date-time
        payload:
          type: object
          description: Message payload (varies by type)

    WebSocketSubscribeMessage:
      type: object
      required:
        - type
        - workspaceId
      properties:
        type:
          type: string
          enum: [subscribe]
        workspaceId:
          type: string
          format: uuid
        eventTypes:
          type: array
          items:
            type: string
          description: Optional filter for specific event types

    WebSocketEventMessage:
      type: object
      required:
        - type
        - event
        - timestamp
      properties:
        type:
          type: string
          enum: [event]
        event:
          type: string
          enum:
            - mission.created
            - mission.started
            - mission.completed
            - mission.failed
            - mission.cancelled
            - stage.started
            - stage.completed
            - stage.failed
            - artifact.created
            - artifact.verified
            - audit.event
        workspaceId:
          type: string
          format: uuid
        missionId:
          type: string
          format: uuid
        payload:
          type: object
        timestamp:
          type: string
          format: date-time

    WebSocketPingMessage:
      type: object
      required:
        - type
      properties:
        type:
          type: string
          enum: [ping]

    WebSocketPongMessage:
      type: object
      required:
        - type
        - timestamp
      properties:
        type:
          type: string
          enum: [pong]
        timestamp:
          type: string
          format: date-time

    WebSocketErrorMessage:
      type: object
      required:
        - type
        - error
        - timestamp
      properties:
        type:
          type: string
          enum: [error]
        error:
          type: string
        message:
          type: string
        timestamp:
          type: string
          format: date-time
