openapi: 3.1.0
info:
  title: Dungbeetle Cloud API
  version: "1"
  description: |
    HTTP API of the Dungbeetle cloud server: run ingestion, hosted baselines,
    screenshots, reviews, and analytics under `/api/v1`, plus the public
    device-code grant flow (`/auth/device/*`), anonymous trial ingest
    (`/anon/runs`), and health probes.

    Every `/api/v1` credential resolves to exactly one repository; everything a
    request can read or write is scoped to that repository.

    **Errors.** All JSON errors share one envelope (see the `ApiError` schema):
    `error` is a human-readable message and `code` is a stable snake_case slug
    for machine classification (`unauthorized`, `invalid_credentials`,
    `invalid_agent_token`, `missing_scope`, `agent_forbidden`, `rate_limited`,
    `validation_error`, `digest_mismatch`, `billing_blocked`, `conflict`,
    `not_found`, `payload_too_large`, `internal`). On `missing_scope` errors a
    `scope` field names the required scope. On the device-flow endpoints the
    `error` field carries RFC 8628 codes (`authorization_pending`, `slow_down`,
    `expired_token`, `access_denied`, `invalid_grant`) and `code` mirrors the
    same value.

    **Versioning.** Every `/api` response carries a `Dungbeetle-Api-Version`
    header (currently `1`).
servers:
  - url: /
    description: Your Dungbeetle server origin (e.g. https://dungbeetle.dev).

tags:
  - name: Health
    description: Unauthenticated liveness/readiness probes.
  - name: Device authorization
    description: RFC 8628-shaped device-code grant flow for minting agent tokens.
  - name: Runs
    description: Run ingestion, listing, retrieval, and review.
  - name: Screenshots
    description: Content-addressed screenshot artifact upload and retrieval.
  - name: Baselines
    description: Hosted baseline versions per target.
  - name: Repository
    description: Identity, analytics, usage, and token self-revocation.
  - name: Anonymous
    description: Unauthenticated trial ingest (pruned after 24h).

paths:
  /health:
    get:
      tags: [Health]
      summary: Liveness check
      operationId: getHealth
      security: []
      responses:
        "200":
          description: Server is up.
          content:
            application/json:
              schema:
                type: object
                required: [status, apiVersion]
                properties:
                  status: { type: string, const: ok }
                  apiVersion: { type: integer, const: 1 }

  /healthz:
    get:
      tags: [Health]
      summary: Readiness probe with version and uptime
      operationId: getHealthz
      security: []
      responses:
        "200":
          description: Server is ready.
          content:
            application/json:
              schema:
                type: object
                required: [status, version, uptime]
                properties:
                  status: { type: string, const: ok }
                  version: { type: string }
                  uptime: { type: integer, description: Seconds since process start. }

  /anon/runs:
    post:
      tags: [Anonymous]
      summary: Ingest an anonymous trial run
      description: |
        Unauthenticated trial ingest. Rate-limited per IP (every upload counts);
        stored runs are public at the returned URL, noindexed, and pruned after
        24 hours.
      operationId: postAnonRun
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RunIngestPayload"
      responses:
        "201":
          description: Run stored.
          content:
            application/json:
              schema:
                type: object
                required: [run]
                properties:
                  run:
                    type: object
                    required: [id, url]
                    properties:
                      id: { type: string }
                      url: { type: string, description: Public page for this anonymous run. }
        "400": { $ref: "#/components/responses/ValidationError" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/device/code:
    post:
      tags: [Device authorization]
      summary: Start a device authorization
      description: |
        An agent starts the RFC 8628-shaped flow here (no credential required),
        shows the human the `user_code` / `verification_uri`, then polls
        `/auth/device/token`. Rate-limited per IP.
      operationId: startDeviceAuthorization
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 80
                  default: Agent
                  description: Agent display name, shown to the approving human.
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/AgentScope" }
                  description: >-
                    Requested scopes. Defaults to the Triage preset
                    (runs:read, baselines:read, analytics:read, reviews:write).
      responses:
        "200":
          description: Authorization created.
          content:
            application/json:
              schema:
                type: object
                required:
                  [device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval]
                properties:
                  device_code: { type: string, description: High-entropy redemption secret. Keep private. }
                  user_code: { type: string, description: "Short human code, shown as XXXX-XXXX." }
                  verification_uri: { type: string }
                  verification_uri_complete: { type: string }
                  expires_in: { type: integer, description: Seconds until the code expires (10 minutes). }
                  interval: { type: integer, description: Minimum polling interval in seconds. }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/device/token:
    post:
      tags: [Device authorization]
      summary: Poll for the agent token
      description: |
        Poll with the `device_code` until the human approves. Non-issued states
        are `400` with an RFC 8628 code in `error` (mirrored in `code`):
        `authorization_pending`, `access_denied`, `expired_token`,
        `invalid_grant` (unknown or already-redeemed code). Polling too fast
        yields `429` with `slow_down` and a `Retry-After` header. Each code is
        redeemable exactly once.
      operationId: pollDeviceToken
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code: { type: string }
      responses:
        "200":
          description: Approved — the agent token, shown exactly once.
          content:
            application/json:
              schema:
                type: object
                required: [token, token_type, label, scopes, repository]
                properties:
                  token: { type: string, description: "Bearer agent token (dbat_ prefix)." }
                  token_type: { type: string, const: bearer }
                  label: { type: string }
                  scopes:
                    type: array
                    items: { $ref: "#/components/schemas/AgentScope" }
                  repository:
                    type: object
                    required: [id]
                    properties:
                      id: { type: string }
                      name: { type: string }
        "400":
          description: >-
            Not (yet) issued. `error`/`code` is one of `authorization_pending`,
            `access_denied`, `expired_token`, `invalid_grant`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: Polling too fast — `error`/`code` is `slow_down`; honor `Retry-After`.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /api/v1/runs:
    post:
      tags: [Runs]
      summary: Ingest a run report
      description: Requires the `runs:write` scope on agent tokens.
      operationId: ingestRun
      x-required-scope: runs:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RunIngestPayload"
      responses:
        "201":
          description: Run stored.
          content:
            application/json:
              schema:
                type: object
                required: [run]
                properties:
                  run: { $ref: "#/components/schemas/StoredRunWithUrl" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/BillingBlocked" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Runs]
      summary: List recent runs
      description: >-
        Newest first. Requires the `runs:read` scope on agent tokens.
      operationId: listRuns
      x-required-scope: runs:read
      parameters:
        - name: limit
          in: query
          description: Max runs to return. Clamped to [1, 200]; default 50.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: branch
          in: query
          description: Only runs pushed from this exact branch.
          schema: { type: string }
      responses:
        "200":
          description: Recent runs.
          content:
            application/json:
              schema:
                type: object
                required: [runs]
                properties:
                  runs:
                    type: array
                    items: { $ref: "#/components/schemas/RunListItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/runs/{id}:
    get:
      tags: [Runs]
      summary: Fetch a stored run and its report
      description: Requires the `runs:read` scope on agent tokens.
      operationId: getRun
      x-required-scope: runs:read
      parameters:
        - $ref: "#/components/parameters/RunId"
      responses:
        "200":
          description: The run and its full ingest report.
          content:
            application/json:
              schema:
                type: object
                required: [run, report]
                properties:
                  run:
                    allOf:
                      - $ref: "#/components/schemas/StoredRunWithUrl"
                      - type: object
                        required: [reviewState]
                        properties:
                          reviewState: { $ref: "#/components/schemas/ReviewState" }
                  report: { $ref: "#/components/schemas/IngestReport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/runs/{id}/screenshots/{artifactId}:
    get:
      tags: [Screenshots]
      summary: Fetch an offloaded screenshot (PNG) by content-addressed id
      description: >-
        Serves the PNG bytes for a `screenshotRefs` artifact id from a run's
        report. Content-addressed, so cacheable immutably. Requires the
        `runs:read` scope on agent tokens.
      operationId: getRunScreenshot
      x-required-scope: runs:read
      parameters:
        - $ref: "#/components/parameters/RunId"
        - name: artifactId
          in: path
          required: true
          description: sha-256 artifact id (64 lowercase hex chars).
          schema: { $ref: "#/components/schemas/Digest" }
      responses:
        "200":
          description: PNG bytes.
          content:
            image/png:
              schema: { type: string, format: binary }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/runs/{id}/review:
    post:
      tags: [Runs]
      summary: Record a review decision on a run
      description: >-
        Approve/reject/pend a run. With `promote: true` on an approval, the
        run's candidate snapshots become new hosted baseline versions. Reviews
        made with an agent token are attributed to the agent ("<label> (agent,
        on behalf of <owner>)"). Requires the `reviews:write` scope on agent
        tokens; `promote: true` additionally requires `baselines:write`
        (promotion writes to the baseline source of truth), so Triage-scoped
        tokens can review but not promote.
      operationId: reviewRun
      x-required-scope: reviews:write
      parameters:
        - $ref: "#/components/parameters/RunId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state: { $ref: "#/components/schemas/ReviewState" }
                note: { type: string }
                promote:
                  type: boolean
                  description: >-
                    Only honored when `state` is `approved`. Agent tokens must
                    also hold the `baselines:write` scope.
      responses:
        "200":
          description: Review recorded.
          content:
            application/json:
              schema:
                type: object
                required: [review, promoted]
                properties:
                  review: { $ref: "#/components/schemas/ReviewEntry" }
                  promoted:
                    type: array
                    items:
                      type: object
                      required: [target, version, deduped]
                      properties:
                        target: { type: string }
                        version: { type: integer }
                        deduped: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/screenshots/probe:
    post:
      tags: [Screenshots]
      summary: Which screenshot digests the repository still needs
      description: >-
        Of the presented digests, returns the subset the repository does not
        already store — the client then uploads only those. Requires the
        `runs:write` scope on agent tokens.
      operationId: probeScreenshots
      x-required-scope: runs:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [digests]
              properties:
                digests:
                  type: array
                  items: { type: string }
                  description: sha-256 digests; entries not matching ^[a-f0-9]{64}$ are ignored.
      responses:
        "200":
          description: Digests missing from the repository.
          content:
            application/json:
              schema:
                type: object
                required: [missing]
                properties:
                  missing:
                    type: array
                    items: { $ref: "#/components/schemas/Digest" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/screenshots/{digest}:
    put:
      tags: [Screenshots]
      summary: Upload a screenshot artifact (content-addressed)
      description: >-
        The raw PNG body must hash (sha-256) to `{digest}`. Idempotent: a digest
        the repository already has returns `200` with `deduped: true` without
        re-storing; a new artifact returns `201`. A body that does not hash to
        the digest is `400` with code `digest_mismatch`. Requires the
        `runs:write` scope on agent tokens.
      operationId: putScreenshot
      x-required-scope: runs:write
      parameters:
        - name: digest
          in: path
          required: true
          description: sha-256 of the body (64 lowercase hex chars).
          schema: { $ref: "#/components/schemas/Digest" }
      requestBody:
        required: true
        content:
          image/png:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Already stored (deduped).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScreenshotIngestResult" }
        "201":
          description: Stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScreenshotIngestResult" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/baselines:
    post:
      tags: [Baselines]
      summary: Upload a baseline version
      description: >-
        Stores a new version for the target, or dedupes (200) when the upload
        matches the latest version's digest. Requires the `baselines:write`
        scope on agent tokens.
      operationId: putBaseline
      x-required-scope: baselines:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BaselineUploadPayload"
      responses:
        "200":
          description: Unchanged — matched the latest version (deduped).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaselineUploadResponse" }
        "201":
          description: New version created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaselineUploadResponse" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/BillingBlocked" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Baselines]
      summary: List baseline targets with their latest version
      description: Requires the `baselines:read` scope on agent tokens.
      operationId: listBaselineTargets
      x-required-scope: baselines:read
      responses:
        "200":
          description: Targets and their latest versions.
          content:
            application/json:
              schema:
                type: object
                required: [targets]
                properties:
                  targets:
                    type: array
                    items: { $ref: "#/components/schemas/BaselineTargetSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/baselines/{target}:
    get:
      tags: [Baselines]
      summary: Version history for a target
      description: Requires the `baselines:read` scope on agent tokens.
      operationId: listBaselineVersions
      x-required-scope: baselines:read
      parameters:
        - $ref: "#/components/parameters/BaselineTarget"
      responses:
        "200":
          description: All versions, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [target, versions]
                properties:
                  target: { type: string }
                  versions:
                    type: array
                    items: { $ref: "#/components/schemas/BaselineVersion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/baselines/{target}/latest:
    get:
      tags: [Baselines]
      summary: Latest baseline version with its snapshot
      description: Requires the `baselines:read` scope on agent tokens.
      operationId: getLatestBaseline
      x-required-scope: baselines:read
      parameters:
        - $ref: "#/components/parameters/BaselineTarget"
      responses:
        "200":
          description: Latest version and the canonical snapshot JSON (as a string).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaselineWithSnapshot" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/baselines/{target}/versions/{version}:
    get:
      tags: [Baselines]
      summary: A specific baseline version with its snapshot
      description: Requires the `baselines:read` scope on agent tokens.
      operationId: getBaselineVersion
      x-required-scope: baselines:read
      parameters:
        - $ref: "#/components/parameters/BaselineTarget"
        - name: version
          in: path
          required: true
          description: Positive integer version number.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The requested version and its snapshot JSON (as a string).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaselineWithSnapshot" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/repository:
    get:
      tags: [Repository]
      summary: Identify the authenticated repository
      description: >-
        Identity probe — allowed for any valid credential; agent tokens need no
        particular scope.
      operationId: getRepository
      responses:
        "200":
          description: The repository these credentials resolve to.
          content:
            application/json:
              schema:
                type: object
                required: [repository]
                properties:
                  repository:
                    type: object
                    required: [id, name, createdAt]
                    properties:
                      id: { type: string }
                      name: { type: string }
                      createdAt: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/analytics:
    get:
      tags: [Repository]
      summary: Trend and per-target flakiness analytics
      description: Requires the `analytics:read` scope on agent tokens.
      operationId: getAnalytics
      x-required-scope: analytics:read
      responses:
        "200":
          description: Windowed analytics for the repository.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Analytics" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/usage:
    get:
      tags: [Repository]
      summary: Current-period usage vs plan limits
      description: >-
        Metering only — limits are not enforced here. Requires the
        `analytics:read` scope on agent tokens.
      operationId: getUsage
      x-required-scope: analytics:read
      responses:
        "200":
          description: Usage for the team that owns this repository.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageReport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/token:
    delete:
      tags: [Repository]
      summary: Self-revoke the presented agent token
      description: >-
        A Bearer agent token may always revoke itself (used by `dungbeetle
        logout`); no particular scope is required. Basic credentials get `400`
        — push tokens are managed in repo Settings.
      operationId: revokeToken
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Token revoked.
          content:
            application/json:
              schema:
                type: object
                required: [revoked]
                properties:
                  revoked: { type: boolean, const: true }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

security:
  - basicAuth: []
  - bearerAuth: []

components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
      description: >-
        Repository client credentials (CI push pair): `client_id` as username,
        `client_secret` as password. All-or-nothing — no scope checks.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: dbat_
      description: >-
        Agent token (`dbat_…`) minted by a human via the device-code flow.
        Scoped and revocable; requests outside the token's scopes get `403`
        with code `missing_scope`.

  parameters:
    RunId:
      name: id
      in: path
      required: true
      description: Run id.
      schema: { type: string }
    BaselineTarget:
      name: target
      in: path
      required: true
      description: Baseline target name.
      schema: { type: string }

  responses:
    ValidationError:
      description: >-
        Invalid body or parameter (`code: validation_error`), or — on screenshot
        upload — a body that does not hash to the digest (`code: digest_mismatch`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    Unauthorized:
      description: >-
        Missing/malformed Authorization header (`code: unauthorized`), bad
        Basic credentials (`code: invalid_credentials`), or an invalid,
        expired, or revoked agent token (`code: invalid_agent_token`).
      headers:
        WWW-Authenticate:
          schema: { type: string }
          description: Set (Basic realm) when the header was missing or malformed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    BillingBlocked:
      description: >-
        The owning account is over a hard plan limit or its subscription is
        paused (`code: billing_blocked`). Existing data is preserved; writes
        resume after upgrading or freeing space.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    Forbidden:
      description: >-
        Agent token outside its granted scopes (`code: missing_scope`, with a
        `scope` field naming the required scope) or a route that is not
        agent-accessible at all (`code: agent_forbidden`). Basic-auth requests
        never see this.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    NotFound:
      description: "No such resource in this repository (`code: not_found`)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    PayloadTooLarge:
      description: >-
        Request body exceeds the configured limit (default 25 MiB)
        (`code: payload_too_large`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RateLimited:
      description: >-
        Too many failed attempts or uploads from this client
        (`code: rate_limited`). Honor the `Retry-After` header; successful
        authenticated requests never count toward the limit.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

  schemas:
    ApiError:
      type: object
      description: >-
        Uniform JSON error envelope. `code` is a stable snake_case slug; on the
        device-flow endpoints it mirrors the RFC 8628 value carried in `error`.
      required: [error, code]
      properties:
        error:
          type: string
          description: Human-readable message.
        code:
          type: string
          description: >-
            Machine-readable slug. One of: unauthorized, invalid_credentials,
            invalid_agent_token, missing_scope, agent_forbidden, rate_limited,
            validation_error, digest_mismatch, billing_blocked, conflict,
            not_found, payload_too_large, internal — or, on /auth/device/token,
            an RFC 8628 code (authorization_pending, slow_down, expired_token,
            access_denied, invalid_grant).
        scope:
          type: string
          description: Present only on `missing_scope` errors — the required scope.

    AgentScope:
      type: string
      enum:
        - runs:read
        - runs:write
        - baselines:read
        - baselines:write
        - reviews:write
        - analytics:read

    Digest:
      type: string
      pattern: "^[a-f0-9]{64}$"
      description: sha-256 hex digest (content-addressed artifact id).

    ReviewState:
      type: string
      enum: [pending, approved, rejected]

    IngestStatus:
      type: string
      enum: [passed, failed, missing, updated, error]

    IngestResult:
      type: object
      description: >-
        One target's result inside a report. Additional fields (diff text,
        snapshot JSON, screenshot refs, per-marker screenshots, error message)
        vary by target kind and capture options.
      required: [name, kind, status]
      properties:
        name: { type: string }
        kind: { type: string }
        status: { $ref: "#/components/schemas/IngestStatus" }
        baselinePath: { type: string }
        diff: { type: string }
        error: { type: string }
        snapshot:
          type: string
          description: Canonical candidate snapshot JSON (runs pushed with --with-snapshots).
        screenshotRefs:
          type: object
          description: Content-addressed screenshot artifact ids, set by the server at ingest.
          properties:
            baseline: { $ref: "#/components/schemas/Digest" }
            candidate: { $ref: "#/components/schemas/Digest" }
            diff: { $ref: "#/components/schemas/Digest" }
      additionalProperties: true

    IngestReport:
      type: object
      required: [mode, passed, project, startedAt, finishedAt, results]
      properties:
        mode: { type: string, enum: [test, update] }
        passed: { type: boolean }
        project: { type: string }
        startedAt: { type: string }
        finishedAt: { type: string }
        results:
          type: array
          items: { $ref: "#/components/schemas/IngestResult" }
      additionalProperties: true

    RunIngestPayload:
      type: object
      required: [report]
      properties:
        branch:
          type: string
          description: Advisory client metadata.
        commit:
          type: string
          description: Advisory client metadata.
        report: { $ref: "#/components/schemas/IngestReport" }

    StoredRun:
      type: object
      required: [id, repositoryId, mode, passed, status, counts, branch, commit, startedAt, finishedAt, createdAt]
      properties:
        id: { type: string }
        repositoryId: { type: string }
        mode: { type: string }
        passed: { type: boolean }
        status: { type: string, enum: [passed, failed] }
        counts:
          type: object
          description: Result counts keyed by status.
          additionalProperties: { type: integer }
        branch: { type: [string, "null"] }
        commit: { type: [string, "null"] }
        startedAt: { type: [string, "null"] }
        finishedAt: { type: [string, "null"] }
        createdAt: { type: string }

    StoredRunWithUrl:
      allOf:
        - $ref: "#/components/schemas/StoredRun"
        - type: object
          required: [url]
          properties:
            url:
              type: string
              description: API URL of this run (`/api/v1/runs/{id}`).

    RunListItem:
      type: object
      required: [id, mode, passed, status, branch, commit, createdAt, reviewState, failedCount, url]
      properties:
        id: { type: string }
        mode: { type: string }
        passed: { type: boolean }
        status: { type: string, enum: [passed, failed] }
        branch: { type: [string, "null"] }
        commit: { type: [string, "null"] }
        createdAt: { type: string }
        reviewState: { $ref: "#/components/schemas/ReviewState" }
        failedCount: { type: integer }
        url:
          type: string
          description: API URL of this run (`/api/v1/runs/{id}`).

    ReviewEntry:
      type: object
      required: [id, runId, state, reviewer, note, createdAt]
      properties:
        id: { type: integer }
        runId: { type: string }
        state: { $ref: "#/components/schemas/ReviewState" }
        reviewer:
          type: string
          description: >-
            The owner's email, or "<label> (agent, on behalf of <owner>)" for
            agent-token reviews.
        note: { type: [string, "null"] }
        createdAt: { type: string }

    ScreenshotIngestResult:
      type: object
      required: [id, deduped]
      properties:
        id: { $ref: "#/components/schemas/Digest" }
        deduped:
          type: boolean
          description: True when the repository already had this artifact.

    BaselineUploadPayload:
      type: object
      required: [target, kind, snapshot]
      properties:
        target: { type: string }
        kind: { type: string }
        snapshot:
          type: string
          description: Canonical baseline JSON, transmitted verbatim as a string.
        screenshot:
          type: string
          description: Optional base64-encoded PNG.

    BaselineVersion:
      type: object
      required: [id, repositoryId, target, kind, version, digest, hasScreenshot, createdAt]
      properties:
        id: { type: string }
        repositoryId: { type: string }
        target: { type: string }
        kind: { type: string }
        version: { type: integer }
        digest: { type: string }
        hasScreenshot: { type: boolean }
        createdAt: { type: string }

    BaselineUploadResponse:
      type: object
      required: [deduped, baseline]
      properties:
        deduped:
          type: boolean
          description: True when the upload matched the latest version's digest.
        baseline: { $ref: "#/components/schemas/BaselineVersion" }

    BaselineWithSnapshot:
      type: object
      required: [baseline, snapshot]
      properties:
        baseline: { $ref: "#/components/schemas/BaselineVersion" }
        snapshot:
          type: string
          description: The canonical baseline JSON, verbatim.

    BaselineTargetSummary:
      type: object
      required: [target, kind, latestVersion, digest, updatedAt]
      properties:
        target: { type: string }
        kind: { type: string }
        latestVersion: { type: integer }
        digest: { type: string }
        updatedAt: { type: string }

    Analytics:
      type: object
      required: [windowRuns, totals, targets, trend]
      properties:
        windowRuns: { type: integer }
        totals:
          type: object
          required: [runs, passed, failed, passRate]
          properties:
            runs: { type: integer }
            passed: { type: integer }
            failed: { type: integer }
            passRate: { type: number }
        targets:
          type: array
          items:
            type: object
            required: [target, kind, observations, failed, failRate, flips, flakeRate, lastStatus]
            properties:
              target: { type: string }
              kind: { type: string }
              observations: { type: integer }
              failed: { type: integer }
              failRate: { type: number }
              flips: { type: integer }
              flakeRate: { type: number }
              lastStatus: { type: string }
        trend:
          type: array
          items:
            type: object
            required: [date, runs, failed]
            properties:
              date: { type: string }
              runs: { type: integer }
              failed: { type: integer }

    UsageReport:
      type: object
      required: [period, plan, metered, snapshots, storageBytes, repos, limits, warnings]
      properties:
        period:
          type: string
          description: Billing period as 'YYYY-MM' (UTC).
        plan:
          type: string
          enum: [free, starter, team, business, enterprise]
        metered:
          type: boolean
          description: False in self-host mode (usage tracked, never enforced).
        snapshots: { type: integer }
        storageBytes: { type: integer }
        repos: { type: integer }
        limits:
          type: object
          description: Per-dimension plan limits; null means unlimited.
          required: [repos, storageBytes, snapshotsPerPeriod, retentionDays]
          properties:
            repos: { type: [integer, "null"] }
            storageBytes: { type: [integer, "null"] }
            snapshotsPerPeriod: { type: [integer, "null"] }
            retentionDays: { type: [integer, "null"] }
        warnings:
          type: array
          items:
            type: object
            required: [dimension, used, limit, status, hard]
            properties:
              dimension: { type: string, enum: [repos, storage, snapshots] }
              used: { type: integer }
              limit: { type: integer }
              status: { type: string, enum: [warning, over] }
              hard: { type: boolean }
