openapi: 3.1.0
info:
  title: Nexora Observe Server API
  # The version of this API contract, kept separately from the Nexora release version: bump it only
  # when a route, parameter, body or response changes, and add the change to website/docs/http-api.md.
  version: 0.5.0
  summary: HTTP API of the `nexora observe` server.
  description: |
    Endpoints served by `nexora observe`: execution control and history, the dead letter queue,
    cron schedules, health probes, Prometheus metrics, and the live process snapshot.

    Every `/api` route requires `Authorization: Bearer <key>`: the server's own key, or a user API
    key from `POST /api/keys` (username and password). The dashboard gets a user key as a session
    cookie from `POST /api/session` instead. A cookie-authenticated request that changes state must
    also send an `Origin` matching this server. Health probes, metrics, the dashboard page and the
    API docs stay public, since probes and scrapers cannot carry credentials. Start the server with
    `--tls-cert` and `--tls-key` (or `--keystore`) so the key does not travel in clear.

    A server started with `--multi-tenant` scopes every execution, history, dead letter, schedule
    and webhook route, and the live stream, to one tenant (see the `X-Tenant-ID` parameter).
    Another tenant's resources answer `404`, exactly like unknown ones. Without the flag every
    caller sees everything and `X-Tenant-ID` is ignored.

    This document is maintained by hand. Every route registered by the server must appear here;
    `OpenApiSpecTest` fails the build when a route is added without updating this file.

    `info.version` is the version of this API contract, not of Nexora: it changes only when the
    API does. The changes in each version, and the Nexora release that first serves it, are listed
    at https://nexora.virul.me/docs/http-api.
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
servers:
  - url: /
    description: The observe server this document was loaded from

# Secure by default; public routes opt out with `security: []`.
security:
  - bearerAuth: []
  - sessionCookie: []

tags:
  - name: Executions
    description: Start, inspect, cancel and replay executions.
  - name: Dead letters
    description: Executions that failed permanently, for review, replay or resolution.
  - name: Schedules
    description: Recurring cron-based executions. Requires a persistence store.
  - name: Webhooks
    description: Audit log of webhook delivery attempts.
  - name: Plugins
    description: Swap plugin JARs on the running engine. Server API key only.
  - name: Session
    description: Login. A username and password mint a user API key; the dashboard gets it as an HttpOnly cookie.
  - name: Health
    description: Liveness and readiness probes. Unauthenticated by design.
  - name: Observability
    description: Prometheus metrics and the live process snapshot.
  - name: UI and docs
    description: The dashboard, this document, and Swagger UI.

paths:
  /api/execute:
    post:
      tags: [Executions]
      operationId: executeIntent
      summary: Start an execution
      description: |
        Plans and starts the goal asynchronously and returns immediately with the new execution id.
        A W3C `traceparent` header continues the caller's trace.
      parameters:
        - $ref: '#/components/parameters/TenantId'
        - name: traceparent
          in: header
          required: false
          description: W3C trace context, e.g. `00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExecuteRequest'
            examples:
              payment:
                summary: Goal with context and a webhook
                value:
                  goal: process payment
                  context: { orderId: ORD-99, amount: 450 }
                  webhookUrl: https://your-api.example.com/webhooks/nexora
                  webhookEvents: [COMPLETED, FAILED]
      responses:
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '202':
          description: Execution accepted and started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecuteAccepted'
              examples:
                accepted:
                  value:
                    accepted: true
                    message: Execution accepted
                    goal: process payment
                    executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
        '400':
          description: Invalid JSON or missing `goal`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecuteRejected'
              examples:
                missingGoal:
                  value: { accepted: false, error: "Field 'goal' is required" }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /api/executions:
    get:
      tags: [Executions]
      operationId: listExecutions
      summary: List execution history
      description: |
        Newest first (`startedAt` descending), cursor-paginated. Pass the returned `cursor` to get the
        next page; it is `null` on the last page. `total` counts every match, not just this page.
        Keep the same filters when following a cursor. Requires a persistence store.
      parameters:
        - $ref: '#/components/parameters/TenantId'
        - name: status
          in: query
          description: One or more execution states, comma-separated, case-insensitive.
          schema:
            type: string
            examples: ['FAILED,CANCELLED']
        - name: goal
          in: query
          description: Case-insensitive substring of the goal.
          schema:
            type: string
            maxLength: 200
        - name: from
          in: query
          description: Inclusive lower bound on `startedAt`, ISO-8601 with an offset. Encode `+` as `%2B`.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Exclusive upper bound on `startedAt`, ISO-8601 with an offset.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: cursor
          in: query
          description: The `cursor` value from the previous page.
          schema:
            type: string
      responses:
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: One page of executions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecutionPage'
              examples:
                page:
                  value:
                    data:
                      - executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                        traceId: 4bf92f3577b34da6a3ce929d0e0e4736
                        goal: process payment
                        state: FAILED
                        startedAt: '2026-10-01T10:00:00.123Z'
                        completedAt: '2026-10-01T10:00:01.456Z'
                        durationMs: 1333
                    cursor: MjAyNi0xMC0wMVQxMDowMDowMC4xMjNafDRmM2ExYzJk
                    total: 42
        '400':
          description: Invalid query parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badLimit:
                  value: { error: limit must be an integer between 1 and 100 }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '500':
          $ref: '#/components/responses/HistoryQueryFailed'
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/executions/{executionId}:
    parameters:
      - $ref: '#/components/parameters/ExecutionId'
    get:
      tags: [Executions]
      operationId: getExecution
      summary: Get one execution with its steps
      description: >-
        Each completed step includes its recorded `output`, up to `persistence.stepOutputMaxBytes`
        (64 KB by default). Outputs can hold business data, so anyone with an API key or a
        dashboard session can read them; set the cap to 0 to record none.
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: The execution, its context and step records.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecutionDetail'
              examples:
                failed:
                  value:
                    executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                    traceId: 4bf92f3577b34da6a3ce929d0e0e4736
                    goal: process payment
                    context: { orderId: ORD-99 }
                    state: FAILED
                    startedAt: '2026-10-01T10:00:00.123Z'
                    completedAt: '2026-10-01T10:00:01.456Z'
                    steps:
                      - stepId: validate
                        capabilityId: validate_order
                        idempotencyKey: 7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
                        state: COMPLETED
                        failureCode: null
                        failureMessage: null
                        startedAt: '2026-10-01T10:00:00.130Z'
                        completedAt: '2026-10-01T10:00:00.190Z'
                        durationMs: 60
                        output: { valid: true, requestId: req-12 }
                        outputTruncated: false
                      - stepId: charge
                        capabilityId: charge_card
                        idempotencyKey: 0b8f6a2e-3c4d-4e5f-8a9b-1c2d3e4f5a6b
                        state: FAILED
                        failureCode: DECLINED
                        failureMessage: card declined
                        startedAt: '2026-10-01T10:00:00.200Z'
                        completedAt: '2026-10-01T10:00:01.400Z'
                        durationMs: 1200
                        output: {}
                        outputTruncated: false
        '404':
          $ref: '#/components/responses/ExecutionNotFound'
        '500':
          description: The store could not be queried.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                storeDown:
                  value: { error: Execution detail query failed, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
        '503':
          $ref: '#/components/responses/PersistenceDisabled'
    delete:
      tags: [Executions]
      operationId: cancelExecution
      summary: Cancel a running execution
      description: |
        Steps that have not started are never started, running steps are interrupted, and the
        execution finishes as `CANCELLED`. Safe to repeat: a second cancel while it is stopping also
        returns `200`.
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Cancellation requested.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelAccepted'
              examples:
                cancelled:
                  value: { cancelled: true, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
        '404':
          $ref: '#/components/responses/ExecutionNotFound'
        '409':
          description: Already finished, or running on another engine instance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                finished:
                  value: { error: Execution has already finished, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
                notOwned:
                  value: { error: Execution is not running on this engine instance, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
        '500':
          description: The store could not be queried.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                failed:
                  value: { error: Cancel request failed, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
        '503':
          description: The cancel request timed out.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                timedOut:
                  value: { error: Cancel request timed out, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }

  /api/executions/{executionId}/replay:
    parameters:
      - $ref: '#/components/parameters/ExecutionId'
    post:
      tags: [Executions]
      operationId: replayExecution
      summary: Replay an unsuccessful execution
      description: |
        Re-runs a `FAILED`, `TIMED_OUT`, `CANCELLED` or `COMPENSATED` execution with the same goal and
        context under a new execution id. The replay's context carries the original id as `_replayOf`.
        Completed steps reuse their idempotency keys unless the original was compensated.
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '202':
          description: Replay started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReplayAccepted'
              examples:
                started:
                  value:
                    accepted: true
                    executionId: 9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
                    replayOf: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
        '404':
          $ref: '#/components/responses/ExecutionNotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '409':
          description: The execution's state does not allow a replay.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotReplayable'
              examples:
                completed:
                  value:
                    error: Only FAILED, TIMED_OUT, CANCELLED and COMPENSATED executions can be replayed
                    executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                    state: COMPLETED
        '500':
          description: The store could not be queried.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                failed:
                  value: { error: Replay request failed, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/plugins/{pluginId}/reload:
    parameters:
      - name: pluginId
        in: path
        required: true
        description: Id of an active plugin, as `nexora plugins` lists it.
        schema:
          type: string
        example: payments
    post:
      tags: [Plugins]
      operationId: reloadPlugin
      summary: Swap a plugin for a new JAR without restarting
      description: |
        Loads the JAR at `jar` under a fresh class loader, initialises it, and swaps its capabilities
        and planners in for the active version's. New executions use the new version at once; steps
        already running finish on the old one, which is shut down when the last of them returns.
        If the new version fails, nothing is swapped and the old one keeps serving (`422`).

        Runs code from the JAR inside the server, so only the server's own API key may call it, on
        any server: user keys and dashboard sessions get `403`. `X-Tenant-ID` is ignored, since a
        plugin serves every tenant. `jar` is an absolute path on the server, to a `.jar` file in the
        same directory as the plugin's current JAR (symlinks resolved). Requires a persistence store.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jar]
              properties:
                jar:
                  type: string
                  description: Absolute path on the server to the new plugin JAR.
            example: { jar: /opt/nexora/plugins/payments-1.3.0.jar }
      responses:
        '200':
          description: The new version is active.
          content:
            application/json:
              schema:
                type: object
                required: [reloaded, pluginId, previousVersion, newVersion]
                properties:
                  reloaded:
                    type: boolean
                    const: true
                  pluginId:
                    type: string
                  previousVersion:
                    type: string
                  newVersion:
                    type: string
              examples:
                reloaded:
                  value: { reloaded: true, pluginId: payments, previousVersion: 1.2.0, newVersion: 1.3.0 }
        '400':
          description: 'The body is not `{"jar": "..."}`, or the path is not an absolute path to a `.jar` file in the plugin''s directory.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                missingJar:
                  value: { error: { code: INVALID_REQUEST, message: "Field 'jar' is required: the new JAR's path on the server" } }
                outsideDirectory:
                  value: { error: { code: JAR_NOT_ALLOWED, message: "The new jar must be in the same directory as the plugin's current jar (/opt/nexora/plugins)" } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The caller authenticated with a user key or dashboard session, not the server API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                forbidden:
                  value: { error: { code: FORBIDDEN, message: 'Reloading a plugin requires the server API key; user keys and sessions cannot do it' } }
        '404':
          description: No active plugin has this id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                notFound:
                  value: { error: { code: PLUGIN_NOT_FOUND, message: No active plugin with id payments } }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '409':
          description: The plugin was registered in code rather than loaded from a JAR.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                inline:
                  value: { error: { code: PLUGIN_NOT_RELOADABLE, message: 'Plugin payments was registered in code, not loaded from a JAR; restart to change it' } }
        '422':
          description: The new version failed to load or initialise. The previous version is still active and serving.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                rolledBack:
                  value: { error: { code: PLUGIN_RELOAD_FAILED, message: "Reload of plugin 'payments' failed; version 1.2.0 is still active: Plugin initialization failed: payments: connection refused" } }
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          description: The reload failed unexpectedly.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                failed:
                  value: { error: { code: INTERNAL_ERROR, message: Plugin reload failed } }
        '503':
          description: The server runs without a persistence store.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginError'
              examples:
                noStore:
                  value: { error: { code: PERSISTENCE_DISABLED, message: Plugin hot reload requires a persistence store; set executionStore in nexora.json or NEXORA_JDBC_URL } }

  /api/webhook-deliveries/{executionId}:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Webhook delivery attempts for an execution
      parameters:
        - $ref: '#/components/parameters/TenantId'
        - $ref: '#/components/parameters/ExecutionId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '404':
          $ref: '#/components/responses/ExecutionNotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Every delivery attempt, possibly empty.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WebhookDelivery'
              examples:
                attempts:
                  value:
                    - deliveryId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                      executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                      url: https://your-api.example.com/webhooks/nexora
                      statusCode: 200
                      attempt: 1
                      successful: true
                      timestamp: '2026-10-01T10:00:02Z'
                      errorMessage: null
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '500':
          description: The store could not be queried.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InternalError'
              examples:
                failed:
                  value: { error: internal, details: Failed to query webhook deliveries }
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/dead-letters:
    get:
      tags: [Dead letters]
      operationId: listDeadLetters
      summary: List dead letters
      parameters:
        - $ref: '#/components/parameters/TenantId'
        - name: state
          in: query
          description: Review state to filter by, or `ALL`.
          schema:
            type: string
            enum: [PENDING, RESOLVED, REPLAYED, ALL]
            default: PENDING
        - name: page
          in: query
          description: Zero-based page number.
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: size
          in: query
          description: Page size; values above 100 are capped at 100.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: One page of dead letters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeadLetterPage'
              examples:
                pending:
                  value:
                    items:
                      - id: 6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a
                        executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                        goal: process payment
                        context: { orderId: ORD-99 }
                        failureCode: STEP_FAILED
                        failureMessage: Payment gateway returned a hard decline
                        failedAt: '2026-10-01T10:00:01.456Z'
                        reviewState: PENDING
                        resolveReason: null
                    page: 0
                    size: 20
        '400':
          description: Unknown `state` value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badState:
                  value: { error: "Invalid state 'OPEN'. Allowed: PENDING, RESOLVED, REPLAYED, ALL" }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/dead-letters/{deadLetterId}/replay:
    parameters:
      - $ref: '#/components/parameters/DeadLetterId'
    post:
      tags: [Dead letters]
      operationId: replayDeadLetter
      summary: Replay a dead letter
      description: |
        Replays the original execution (see `POST /api/executions/{executionId}/replay`) and marks
        the dead letter `REPLAYED`. Falls back to the dead letter's own goal and context when the
        original execution record no longer exists.
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '202':
          description: Replay started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeadLetterReplayAccepted'
              examples:
                started:
                  value:
                    accepted: true
                    deadLetterId: 6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a
                    executionId: 9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
        '404':
          $ref: '#/components/responses/DeadLetterNotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowedJson'
        '409':
          description: Not `PENDING`, or the original execution is still running or compensating.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notPending:
                  value: { error: Dead letter is not in PENDING state }
                compensating:
                  value: { error: Original execution is COMPENSATING and cannot be replayed yet }
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/dead-letters/{deadLetterId}/resolve:
    parameters:
      - $ref: '#/components/parameters/DeadLetterId'
    post:
      tags: [Dead letters]
      operationId: resolveDeadLetter
      summary: Mark a dead letter resolved
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveRequest'
            examples:
              withReason:
                value: { reason: 'False positive, investigated' }
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Dead letter marked `RESOLVED`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResolveAccepted'
              examples:
                resolved:
                  value: { resolved: true, deadLetterId: 6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a }
        '404':
          $ref: '#/components/responses/DeadLetterNotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowedJson'
        '503':
          $ref: '#/components/responses/PersistenceDisabled'

  /api/schedules:
    get:
      tags: [Schedules]
      operationId: listSchedules
      summary: List all cron schedules
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Active and inactive schedules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduleList'
              examples:
                nightly:
                  value:
                    schedules:
                      - id: 18af1c72-3b4c-4d5e-8f6a-7b8c9d0e1f2a
                        cronExpression: 0 0 * * *
                        goal: nightly cleanup
                        missedFirePolicy: FIRE_ONCE
                        active: true
                        nextFireAt: '2026-10-02T00:00:00Z'
                        lastFiredAt: null
                        lastStatus: NEVER_RUN
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '500':
          $ref: '#/components/responses/ScheduleFailure'
        '503':
          $ref: '#/components/responses/SchedulingUnavailable'
    post:
      tags: [Schedules]
      operationId: createSchedule
      summary: Register a cron schedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateScheduleRequest'
            examples:
              nightly:
                value: { cronExpression: 0 0 * * *, goal: nightly cleanup, missedFirePolicy: SKIP }
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '201':
          description: Schedule registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduleCreated'
              examples:
                created:
                  value: { id: 18af1c72-3b4c-4d5e-8f6a-7b8c9d0e1f2a, nextFireAt: '2026-10-02T00:00:00Z' }
        '400':
          description: Invalid JSON, missing fields, bad cron expression, or unknown policy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missingFields:
                  value: { error: cronExpression and goal are required }
                badPolicy:
                  value: { error: 'Invalid missedFirePolicy: SOMETIMES' }
        '500':
          $ref: '#/components/responses/ScheduleFailure'
        '503':
          $ref: '#/components/responses/SchedulingUnavailable'

  /api/schedules/{scheduleId}:
    delete:
      tags: [Schedules]
      operationId: cancelSchedule
      summary: Cancel a cron schedule
      parameters:
        - $ref: '#/components/parameters/TenantId'
        - name: scheduleId
          in: path
          required: true
          description: Schedule id from `GET /api/schedules`.
          schema:
            type: string
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '404':
          description: Multi-tenant server only. No schedule with this id belongs to the request's tenant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value: { error: Schedule not found }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Schedule cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduleCancelled'
              examples:
                cancelled:
                  value: { cancelled: true, id: 18af1c72-3b4c-4d5e-8f6a-7b8c9d0e1f2a }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '500':
          $ref: '#/components/responses/ScheduleFailure'
        '503':
          $ref: '#/components/responses/SchedulingUnavailable'

  /api/keys:
    post:
      tags: [Session]
      operationId: issueUserApiKey
      summary: Log in with a username and password and get a user API key
      description: |
        Returns a user API key (`nxu_...`) in the body, for the CLI (`nexora login`) and scripts to
        send as `Authorization: Bearer`. It works on every route the server's own key does, except
        that it cannot open a dashboard session at `POST /api/session` (sign in there with the
        username and password). It is recorded
        under the user's name in the audit log, and is valid for 12 hours. Removing the user,
        changing their password, `DELETE /api/session` or a server restart revoke it. Five failed
        attempts lock a username for 15 minutes, and each client is limited to 20 attempts a minute.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordLogin'
            example: { username: alice, password: 'correct horse battery' }
      responses:
        '201':
          description: Key issued. Not set as a cookie.
          content:
            application/json:
              schema:
                type: object
                required: [apiKey, user, tenantId, expiresAt]
                properties:
                  apiKey:
                    type: string
                    pattern: '^nxu_[A-Za-z0-9_-]{43}$'
                  user:
                    type: string
                  tenantId:
                    type: string
                    description: The user's tenant; the key reaches only that tenant on a multi-tenant server.
                  expiresAt:
                    type: string
                    format: date-time
              examples:
                issued:
                  value: { apiKey: nxu_x8Q2r1kV0bLm7dQe9sTzA4hJcN6wPfY3uGiK5oR0aEw, user: alice, tenantId: acme, expiresAt: '2026-10-14T22:00:00Z' }
        '400':
          $ref: '#/components/responses/InvalidLoginBody'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '415':
          $ref: '#/components/responses/LoginNotJson'
        '429':
          $ref: '#/components/responses/TooManyLoginAttempts'

  /api/session:
    get:
      tags: [Session]
      operationId: describeSession
      summary: Who is signed in
      description: Reports the user behind the session cookie or Bearer user key.
      responses:
        '200':
          description: The caller.
          content:
            application/json:
              schema:
                type: object
                required: [credential, passwordLogin]
                properties:
                  user:
                    type: [string, 'null']
                    description: Null when signed in with the server's own key.
                  credential:
                    type: string
                    enum: [API_KEY, USER_KEY, SESSION]
                  expiresAt:
                    type: [string, 'null']
                    format: date-time
                  passwordLogin:
                    type: boolean
                    description: Whether this server has dashboard users.
                  tenantId:
                    type: [string, 'null']
                    description: The tenant the credential is bound to; null for the server's own key, which names one per request.
                  multiTenant:
                    type: boolean
                    description: Whether the server scopes requests to tenants (`--multi-tenant`).
              examples:
                user:
                  value: { user: alice, tenantId: acme, multiTenant: true, credential: SESSION, expiresAt: '2026-10-14T22:00:00Z', passwordLogin: true }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Session]
      operationId: openSession
      summary: Sign in to the dashboard
      description: |
        Mints a user API key from a username and password, or a session from the server's own key
        (`Authorization: Bearer`), and sets it as the cookie (`HttpOnly; SameSite=Strict`, 12 hours)
        so page script never sees it. A user key or cookie cannot mint another. Password sign-in
        must be `application/json` and, when the browser sends an `Origin`, come from this server.
        The session is bound to the user's tenant; on a multi-tenant server a key-holder session is
        bound to the tenant named in `X-Tenant-ID`, which is then required (`400 TENANT_REQUIRED`).
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordLogin'
            example: { username: alice, password: 'correct horse battery' }
      responses:
        '200':
          description: Signed in. The key is in `Set-Cookie`, not the body.
          headers:
            Set-Cookie:
              description: '`nexora_session=nxu_...; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`, plus `Secure` when the server runs with TLS'
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [expiresAt]
                properties:
                  user:
                    type: [string, 'null']
                  tenantId:
                    type: [string, 'null']
                    description: The tenant the session is bound to; null for a key-holder session on a single-tenant server.
                  expiresAt:
                    type: string
                    format: date-time
              examples:
                opened:
                  value: { user: alice, tenantId: acme, expiresAt: '2026-10-14T22:00:00Z' }
        '400':
          $ref: '#/components/responses/InvalidLoginBody'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '415':
          $ref: '#/components/responses/LoginNotJson'
        '429':
          $ref: '#/components/responses/TooManyLoginAttempts'
    delete:
      tags: [Session]
      operationId: closeSession
      summary: Sign out
      description: Revokes the session cookie or the Bearer user key and clears the cookie. The server's own key cannot be revoked.
      responses:
        '200':
          description: Signed out. `Set-Cookie` clears the cookie.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    const: LOGGED_OUT
              examples:
                closed:
                  value: { status: LOGGED_OUT }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /health/live:
    get:
      security: []
      tags: [Health]
      operationId: liveness
      summary: Liveness probe
      description: '`200` while the process is serving HTTP. Checks nothing else.'
      responses:
        '200':
          description: Process is up.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveStatus'
              examples:
                up:
                  value: { status: UP }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /health/ready:
    get:
      security: []
      tags: [Health]
      operationId: readiness
      summary: Readiness probe
      description: |
        `200` when persistence, schema migrations, plugins and the executor are all `UP`; `503`
        with the per-check breakdown otherwise. `migrations` is `DOWN` when a schema migration is
        recorded as failed or the database schema is older than this release expects. Capability
        circuit state does not affect readiness.
      responses:
        '200':
          description: Ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Readiness'
              examples:
                ready:
                  value: { status: UP, checks: { persistence: UP, migrations: UP, plugins: UP, executor: UP } }
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '503':
          description: Not ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Readiness'
              examples:
                dbDown:
                  value: { status: DOWN, checks: { persistence: DOWN, migrations: DOWN, plugins: UP, executor: UP } }
                migrationFailed:
                  value: { status: DOWN, checks: { persistence: UP, migrations: DOWN, plugins: UP, executor: UP } }

  /health:
    get:
      security: []
      tags: [Health]
      operationId: healthSummary
      summary: Health summary
      description: Readiness plus the Nexora version and capability circuit states (informational).
      responses:
        '200':
          description: Ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthSummary'
              examples:
                up:
                  value:
                    status: UP
                    version: 0.4.0
                    checks: { persistence: UP, migrations: UP, plugins: UP, executor: UP }
                    capabilities:
                      - capabilityId: charge_card
                        state: CLOSED
                        sampleCount: 120
                        errorRate: 0.01
                        p99Latency: PT0.25S
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'
        '503':
          description: Not ready; same body shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthSummary'
              examples:
                down:
                  value:
                    status: DOWN
                    version: 0.3.0
                    checks: { persistence: DOWN, migrations: DOWN, plugins: UP, executor: UP }
                    capabilities: []

  /metrics:
    get:
      security: []
      tags: [Observability]
      operationId: prometheusMetrics
      summary: Prometheus metrics
      responses:
        '200':
          description: Prometheus text exposition format.
          content:
            text/plain:
              schema:
                type: string
              examples:
                sample:
                  value: |
                    # TYPE nexora_plan_started_total counter
                    nexora_plan_started_total 42.0
                    # TYPE nexora_ready gauge
                    nexora_ready 1.0
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /api/process:
    get:
      tags: [Observability]
      operationId: processSnapshot
      summary: Live process snapshot
      description: |
        In-memory view of recent executions with per-step timelines, as streamed to the dashboard
        over WebSocket. Not persisted; use `/api/executions` for history.
      parameters:
        - $ref: '#/components/parameters/TenantId'
      responses:
        '400':
          $ref: '#/components/responses/TenantRequired'
        '403':
          $ref: '#/components/responses/TenantForbidden'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '200':
          description: Current snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProcessSnapshot'
              examples:
                running:
                  value:
                    generatedAtMs: 1791636000000
                    activeExecutions: 1
                    executions:
                      - executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
                        traceId: 4bf92f3577b34da6a3ce929d0e0e4736
                        status: RUNNING
                        startedAtMs: 1791635999000
                        finishedAtMs: 0
                        totalSteps: 2
                        runningSteps: 1
                        completedSteps: 1
                        failedSteps: 0
                        skippedSteps: 0
                        steps:
                          - stepId: validate
                            capabilityId: validate_order
                            status: COMPLETED
                            startedAtMs: 1791635999010
                            finishedAtMs: 1791635999040
                            elapsedMs: 30
                            failureCode: null
                            failureMessage: null
                        timeline:
                          - occurredAtMs: 1791635999000
                            type: plan_started
                            message: Execution started
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /:
    get:
      security: []
      tags: [UI and docs]
      operationId: dashboard
      summary: Process observer dashboard
      description: The live dashboard. Also served at `/index.html`.
      responses:
        '200':
          description: Dashboard HTML.
          content:
            text/html:
              schema:
                type: string
              examples:
                page:
                  value: <!DOCTYPE html><html>...</html>
        '404':
          $ref: '#/components/responses/NotFoundText'
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /openapi.yaml:
    get:
      security: []
      tags: [UI and docs]
      operationId: openApiDocument
      summary: This OpenAPI document
      responses:
        '200':
          description: OpenAPI 3.1 document.
          content:
            application/yaml:
              schema:
                type: string
              examples:
                document:
                  value: 'openapi: 3.1.0'
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /docs:
    get:
      security: []
      tags: [UI and docs]
      operationId: swaggerUi
      summary: Swagger UI for this document
      description: Served from assets bundled in the server; makes no external requests.
      responses:
        '200':
          description: Swagger UI HTML.
          content:
            text/html:
              schema:
                type: string
              examples:
                page:
                  value: <!DOCTYPE html><html>...</html>
        '405':
          $ref: '#/components/responses/MethodNotAllowedText'

  /docs/{asset}:
    get:
      security: []
      tags: [UI and docs]
      operationId: swaggerUiAsset
      summary: Swagger UI static asset
      parameters:
        - name: asset
          in: path
          required: true
          description: One of the bundled Swagger UI files.
          schema:
            type: string
            enum: [swagger-ui.css, swagger-ui-bundle.js, swagger-ui-init.js, favicon-32x32.png]
      responses:
        '200':
          description: The asset.
          content:
            text/css:
              schema:
                type: string
            text/javascript:
              schema:
                type: string
            image/png:
              schema:
                type: string
                contentMediaType: image/png
              examples:
                icon:
                  value: (binary PNG)
        '404':
          $ref: '#/components/responses/NotFoundText'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        The observe server's API key. `nexora observe` prints a generated key once on first start
        and keeps only its SHA-256 digest; `NEXORA_API_KEY` or `--api-key` supplies one instead.
    sessionCookie:
      type: apiKey
      in: cookie
      name: nexora_session
      description: Set by `POST /api/session` for the dashboard.
  parameters:
    TenantId:
      name: X-Tenant-ID
      in: header
      required: false
      description: |
        The tenant the request acts for. Ignored unless the server runs with `--multi-tenant`.
        There, the server API key must send it (missing: `400 TENANT_REQUIRED`), while a user key or
        session is bound to the user's tenant and may only repeat it (another tenant:
        `403 TENANT_FORBIDDEN`). An invalid value is `400 INVALID_TENANT_ID`. Operations that already
        answer `400` for their own input use the same status for these tenant errors.
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9_-]{0,63}$'
      example: acme
    ExecutionId:
      name: executionId
      in: path
      required: true
      description: Execution id, as returned by `POST /api/execute` or listed by `GET /api/executions`.
      schema:
        type: string
      example: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c
    DeadLetterId:
      name: deadLetterId
      in: path
      required: true
      description: Dead letter id from `GET /api/dead-letters`.
      schema:
        type: string
      example: 6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a

  responses:
    RateLimitExceeded:
      description: |
        This client made more requests this minute than the server allows: 60 to the API by default,
        10 of them to `POST /api/execute` (`observe.rateLimit` in `nexora.json`). Sent only after
        authentication, so an unauthenticated request gets `401` instead and spends nothing. Retry
        after the number of seconds in `Retry-After`.
      headers:
        Retry-After:
          description: Seconds until this client's window refills. Clients sharing an address (behind one NAT) share the window, so another may use the room first.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RateLimitError'
          examples:
            limited:
              value: { error: { code: RATE_LIMIT_EXCEEDED, message: Too many requests; retry after 42 seconds, retryAfterSeconds: 42 } }
    TenantRequired:
      description: Multi-tenant server only. The server API key sent no `X-Tenant-ID`, or the header is not a valid tenant id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            missing:
              value: { error: { code: TENANT_REQUIRED, message: 'This server is multi-tenant: send X-Tenant-ID to name the tenant' } }
            invalid:
              value: { error: { code: INVALID_TENANT_ID, message: "X-Tenant-ID must be 1 to 64 characters of a-z, 0-9, '-' or '_', starting with a letter or digit" } }
    TenantForbidden:
      description: Multi-tenant server only. `X-Tenant-ID` names a tenant other than the one the user key or session is bound to.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            forbidden:
              value: { error: { code: TENANT_FORBIDDEN, message: This credential cannot act for the requested tenant } }
    InvalidLoginBody:
      description: 'The body is not `{"username": "...", "password": "..."}`, or is over 4 KB.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            badBody:
              value: { error: { code: INVALID_CREDENTIALS_FORMAT, message: 'Body must be {"username":"...","password":"..."}' } }
    LoginNotJson:
      description: Credentials must be sent as `application/json`, which a cross-site page cannot send without a preflight.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            notJson:
              value: { error: { code: UNSUPPORTED_MEDIA_TYPE, message: Send credentials as application/json } }
    TooManyLoginAttempts:
      description: The username is locked (5 failures, 15 minutes) or this client made over 20 attempts this minute. Unknown usernames are throttled the same way.
      headers:
        Retry-After:
          description: Seconds until another attempt is allowed.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            throttled:
              value: { error: { code: TOO_MANY_ATTEMPTS, message: 'Too many login attempts; try again later' } }
    Unauthorized:
      description: Missing or invalid API key or session. Sent before any other check, so it does not reveal whether the route exists.
      headers:
        WWW-Authenticate:
          schema:
            type: string
            const: Bearer realm="nexora"
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
          examples:
            unauthorized:
              value:
                error:
                  code: UNAUTHORIZED
                  message: 'A valid API key is required: send Authorization: Bearer <key>'
    MethodNotAllowedText:
      description: HTTP method not supported on this path.
      headers:
        Allow:
          description: Supported methods, sent by some routes.
          schema:
            type: string
      content:
        text/plain:
          schema:
            type: string
          examples:
            notAllowed:
              value: Method Not Allowed
    MethodNotAllowedJson:
      description: HTTP method not supported on this path.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notAllowed:
              value: { error: Method Not Allowed }
    NotFoundText:
      description: Unknown path.
      content:
        text/plain:
          schema:
            type: string
          examples:
            notFound:
              value: Not Found
    PersistenceDisabled:
      description: The server has no persistence store configured.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            disabled:
              value: { error: Persistence disabled }
    ExecutionNotFound:
      description: No execution with this id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value: { error: Execution not found, executionId: 4f3a1c2d-8b6e-4c1f-9a2b-0d4e5f6a7b8c }
    DeadLetterNotFound:
      description: No dead letter with this id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value: { error: Dead letter not found }
    HistoryQueryFailed:
      description: The store could not be queried.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            failed:
              value: { error: Execution history query failed }
    SchedulingUnavailable:
      description: Cron scheduling needs a persistence store.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            noStore:
              value: { error: Cron scheduling requires a persistence store. Call builder.withExecutionStore(...) first. }
    ScheduleFailure:
      description: Unexpected scheduler failure.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            failed:
              value: { error: Failed to persist schedule }

  schemas:
    RateLimitError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryAfterSeconds]
          properties:
            code:
              type: string
              const: RATE_LIMIT_EXCEEDED
            message:
              type: string
            retryAfterSeconds:
              type: integer
              minimum: 1
              description: Same value as the Retry-After header.
    PasswordLogin:
      type: object
      required: [username, password]
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 32
        password:
          type: string
          minLength: 1
          maxLength: 128
    AuthError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [UNAUTHORIZED, INVALID_CREDENTIALS_FORMAT, UNSUPPORTED_MEDIA_TYPE, TOO_MANY_ATTEMPTS, TENANT_REQUIRED, INVALID_TENANT_ID, TENANT_FORBIDDEN]
            message:
              type: string
    PluginError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [INVALID_REQUEST, INVALID_JAR_PATH, JAR_NOT_ALLOWED, FORBIDDEN, PLUGIN_NOT_FOUND, PLUGIN_NOT_RELOADABLE, PLUGIN_RELOAD_FAILED, INTERNAL_ERROR, PERSISTENCE_DISABLED]
            message:
              type: string
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable reason.
        executionId:
          type: string
          description: Present on execution-scoped errors.
      additionalProperties: true
    InternalError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          const: internal
        details:
          type: [string, 'null']
    ExecutionState:
      type: string
      description: Persisted execution state.
      enum: [RUNNING, COMPLETED, FAILED, TIMED_OUT, CANCELLED, COMPENSATING, COMPENSATED]
    ExecutionStatus:
      type: string
      description: Engine-level execution status, used to select webhook events.
      enum: [PENDING, RUNNING, COMPLETED, FAILED, TIMED_OUT, CANCELLED]
    StepState:
      type: string
      enum: [PENDING, RUNNING, COMPLETED, FAILED, SKIPPED, COMPENSATING, COMPENSATED, COMPENSATION_FAILED]
    HealthStatus:
      type: string
      enum: [UP, DOWN]

    ExecuteRequest:
      type: object
      required: [goal]
      properties:
        goal:
          type: string
          minLength: 1
        context:
          type: object
          additionalProperties: true
        webhookUrl:
          type: string
          format: uri
        webhookEvents:
          type: array
          description: Terminal statuses that trigger the webhook. Defaults to all terminal statuses.
          items:
            $ref: '#/components/schemas/ExecutionStatus'
    ExecuteAccepted:
      type: object
      required: [accepted, message, goal, executionId]
      properties:
        accepted:
          type: boolean
          const: true
        message:
          type: string
        goal:
          type: string
        executionId:
          type: string
    ExecuteRejected:
      type: object
      required: [accepted, error]
      properties:
        accepted:
          type: boolean
          const: false
        error:
          type: string

    ExecutionSummary:
      type: object
      required: [executionId, goal, state, startedAt]
      properties:
        executionId:
          type: string
        traceId:
          type: [string, 'null']
        goal:
          type: string
        state:
          $ref: '#/components/schemas/ExecutionState'
        startedAt:
          type: string
          format: date-time
        completedAt:
          type: [string, 'null']
          format: date-time
        durationMs:
          type: [integer, 'null']
          description: '`null` while running.'
    ExecutionPage:
      type: object
      required: [data, cursor, total]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ExecutionSummary'
        cursor:
          type: [string, 'null']
          description: Opaque token for the next page; `null` on the last page.
        total:
          type: integer
          minimum: 0
    StepRecord:
      type: object
      required: [stepId, capabilityId, state, durationMs, output, outputTruncated]
      properties:
        stepId:
          type: string
        capabilityId:
          type: string
        idempotencyKey:
          type: [string, 'null']
        state:
          $ref: '#/components/schemas/StepState'
        failureCode:
          type: [string, 'null']
        failureMessage:
          type: [string, 'null']
        startedAt:
          type: [string, 'null']
          format: date-time
        completedAt:
          type: [string, 'null']
          format: date-time
        durationMs:
          type: integer
        output:
          $ref: '#/components/schemas/StepOutput'
        outputTruncated:
          type: boolean
          description: >-
            True when the output was larger than `persistence.stepOutputMaxBytes` (64 KB by
            default) and `output` holds only a `preview`.
    StepOutput:
      type: object
      additionalProperties: true
      description: >-
        What the step returned, as JSON. A map output appears as is; any other value appears
        under `value`. Empty for steps that returned nothing, have not completed, or failed, and
        when the server records no outputs (`stepOutputMaxBytes: 0`). When truncated, the only
        key is `preview`: the start of the output's JSON text.
      examples:
        - paymentId: p-1
          status: CAPTURED
        - value: charged
        - preview: '{"blob":"xxxxxxxx'
    ExecutionDetail:
      type: object
      required: [executionId, goal, context, state, startedAt, steps]
      properties:
        executionId:
          type: string
        traceId:
          type: [string, 'null']
        goal:
          type: string
        context:
          type: object
          additionalProperties: true
        state:
          $ref: '#/components/schemas/ExecutionState'
        startedAt:
          type: string
          format: date-time
        completedAt:
          type: [string, 'null']
          format: date-time
        steps:
          type: array
          items:
            $ref: '#/components/schemas/StepRecord'
        tenantId:
          type: string
          description: The tenant this belongs to; `default` on a single-tenant server.
    CancelAccepted:
      type: object
      required: [cancelled, executionId]
      properties:
        cancelled:
          type: boolean
          const: true
        executionId:
          type: string
    ReplayAccepted:
      type: object
      required: [accepted, executionId, replayOf]
      properties:
        accepted:
          type: boolean
          const: true
        executionId:
          type: string
          description: Id of the new execution.
        replayOf:
          type: string
          description: Id of the execution being replayed.
    NotReplayable:
      type: object
      required: [error, executionId, state]
      properties:
        error:
          type: string
        executionId:
          type: string
        state:
          $ref: '#/components/schemas/ExecutionState'

    WebhookDelivery:
      type: object
      required: [deliveryId, executionId, url, statusCode, attempt, successful, timestamp]
      properties:
        deliveryId:
          type: string
        executionId:
          type: string
        url:
          type: string
        statusCode:
          type: integer
        attempt:
          type: integer
        successful:
          type: boolean
        timestamp:
          type: string
          format: date-time
        errorMessage:
          type: [string, 'null']

    DeadLetter:
      type: object
      required: [id, executionId, goal, failedAt, reviewState]
      properties:
        id:
          type: string
        executionId:
          type: string
        goal:
          type: string
        context:
          type: object
          additionalProperties: true
        failureCode:
          type: [string, 'null']
        failureMessage:
          type: [string, 'null']
        failedAt:
          type: string
          format: date-time
        reviewState:
          type: string
          enum: [PENDING, RESOLVED, REPLAYED]
        resolveReason:
          type: [string, 'null']
        tenantId:
          type: string
          description: The tenant this belongs to; `default` on a single-tenant server.
    DeadLetterPage:
      type: object
      required: [items, page, size]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/DeadLetter'
        page:
          type: integer
        size:
          type: integer
    DeadLetterReplayAccepted:
      type: object
      required: [accepted, deadLetterId, executionId]
      properties:
        accepted:
          type: boolean
          const: true
        deadLetterId:
          type: string
        executionId:
          type: string
          description: Id of the new execution.
    ResolveRequest:
      type: object
      properties:
        reason:
          type: string
    ResolveAccepted:
      type: object
      required: [resolved, deadLetterId]
      properties:
        resolved:
          type: boolean
          const: true
        deadLetterId:
          type: string

    Schedule:
      type: object
      required: [id, cronExpression, goal, missedFirePolicy, active, nextFireAt, lastStatus]
      properties:
        id:
          type: string
        cronExpression:
          type: string
          description: 5-field UNIX cron expression.
        goal:
          type: string
        missedFirePolicy:
          $ref: '#/components/schemas/MissedFirePolicy'
        active:
          type: boolean
        nextFireAt:
          type: string
          format: date-time
        lastFiredAt:
          type: [string, 'null']
          format: date-time
        lastStatus:
          type: string
          enum: [NEVER_RUN, FIRED, FAILED, MISSED, CANCELED]
        tenantId:
          type: string
          description: The tenant this belongs to; `default` on a single-tenant server.
    MissedFirePolicy:
      type: string
      enum: [SKIP, FIRE_ONCE, FIRE_ALL]
      default: FIRE_ONCE
    ScheduleList:
      type: object
      required: [schedules]
      properties:
        schedules:
          type: array
          items:
            $ref: '#/components/schemas/Schedule'
    CreateScheduleRequest:
      type: object
      required: [cronExpression, goal]
      properties:
        cronExpression:
          type: string
        goal:
          type: string
        missedFirePolicy:
          $ref: '#/components/schemas/MissedFirePolicy'
    ScheduleCreated:
      type: object
      required: [id, nextFireAt]
      properties:
        id:
          type: string
        nextFireAt:
          type: string
          format: date-time
    ScheduleCancelled:
      type: object
      required: [cancelled, id]
      properties:
        cancelled:
          type: boolean
          const: true
        id:
          type: string

    LiveStatus:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: UP
    ReadinessChecks:
      type: object
      required: [persistence, migrations, plugins, executor]
      properties:
        persistence:
          $ref: '#/components/schemas/HealthStatus'
        migrations:
          $ref: '#/components/schemas/HealthStatus'
        plugins:
          $ref: '#/components/schemas/HealthStatus'
        executor:
          $ref: '#/components/schemas/HealthStatus'
    Readiness:
      type: object
      required: [status, checks]
      properties:
        status:
          $ref: '#/components/schemas/HealthStatus'
        checks:
          $ref: '#/components/schemas/ReadinessChecks'
    CapabilitySnapshot:
      type: object
      required: [capabilityId, state, sampleCount, errorRate, p99Latency]
      properties:
        capabilityId:
          type: string
        state:
          type: string
          enum: [CLOSED, OPEN, HALF_OPEN]
        sampleCount:
          type: integer
        errorRate:
          type: number
        p99Latency:
          type: string
          description: ISO-8601 duration, e.g. `PT0.25S`.
    HealthSummary:
      type: object
      required: [status, version, checks, capabilities]
      properties:
        status:
          $ref: '#/components/schemas/HealthStatus'
        version:
          type: string
          description: The Nexora release the server runs (from its jar), not this document's `info.version`; `unknown` when run from unpackaged classes.
        checks:
          $ref: '#/components/schemas/ReadinessChecks'
        capabilities:
          type: array
          items:
            $ref: '#/components/schemas/CapabilitySnapshot'

    ProcessSnapshot:
      type: object
      required: [generatedAtMs, activeExecutions, executions]
      properties:
        generatedAtMs:
          type: integer
          description: Epoch milliseconds.
        activeExecutions:
          type: integer
        executions:
          type: array
          items:
            $ref: '#/components/schemas/ExecutionSnapshot'
    ExecutionSnapshot:
      type: object
      properties:
        executionId:
          type: string
        traceId:
          type: [string, 'null']
        status:
          type: string
          enum: [RUNNING, COMPLETED, FAILED, TIMED_OUT, CANCELLED]
        startedAtMs:
          type: integer
        finishedAtMs:
          type: integer
          description: 0 while running.
        totalSteps:
          type: integer
        runningSteps:
          type: integer
        completedSteps:
          type: integer
        failedSteps:
          type: integer
        skippedSteps:
          type: integer
        steps:
          type: array
          items:
            $ref: '#/components/schemas/StepSnapshot'
        timeline:
          type: array
          items:
            $ref: '#/components/schemas/TimelineEntry'
        tenantId:
          type: string
          description: The tenant this belongs to; `default` on a single-tenant server.
    StepSnapshot:
      type: object
      properties:
        stepId:
          type: string
        capabilityId:
          type: string
        status:
          type: string
        startedAtMs:
          type: integer
        finishedAtMs:
          type: integer
        elapsedMs:
          type: integer
        failureCode:
          type: [string, 'null']
        failureMessage:
          type: [string, 'null']
        output:
          $ref: '#/components/schemas/StepOutput'
        outputTruncated:
          type: boolean
          description: >-
            True when `output` holds only a `preview`. The snapshot keeps at most 4 KB of each
            output, since it is resent on every event; the execution detail route returns the
            stored output, up to the server's cap.
    TimelineEntry:
      type: object
      properties:
        occurredAtMs:
          type: integer
        type:
          type: string
        message:
          type: string
