openapi: 3.1.0
info:
  title: Regulatory Snapshot API
  version: '1.0.0'
  description: |
    Public HTTP API for Regulatory Snapshot. Programmatic access to scans,
    snapshots, exports, and usage.

    **Authentication.** All `/v1/*` endpoints (except `/v1/keys/*` and the
    Clerk-session variants of `/v1/usage` and `/v1/usage/budget`) require a
    bearer key. `Authorization: Bearer regsn_live_<32 chars>`.

    **Idempotency.** `POST /v1/scans` and `POST /v1/exports` accept an
    optional `Idempotency-Key` header (1-255 printable ASCII chars).
    Stripe-style 24-hour replay window; replays carry an
    `Idempotency-Replayed: true` header. Mismatched body returns
    409 `idempotency_key_in_use`; a still-running first request returns
    409 `idempotency_in_progress`.

    **Errors.** Every error response is `application/problem+json` per
    RFC 9457 with shape `{ type, title, status, detail, instance, code,
    request_id, errors }`. Every response carries an `X-Request-Id`.

    **Budget.** All scan + export work depletes the same Clerk budget pool
    consumed by the regsn.app UI. There is no separate API meter.
servers:
  - url: https://api.regsn.app
  - url: http://localhost:3001

security:
  - bearerAuth: []

paths:
  /v1/keys:
    post:
      summary: Create a new API key
      description: |
        Returns the raw key exactly once. Subsequent listings show only the
        masked prefix. Required Clerk session (dashboard-only).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  example: production-backend
      responses:
        '201':
          description: Key created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyWithRaw' }
        '422':
          $ref: '#/components/responses/Problem'
    get:
      summary: List API keys (masked)
      security: []
      responses:
        '200':
          description: Keys list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }

  /v1/keys/{id}/revoke:
    post:
      summary: Revoke an API key
      security: []
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  revoked_at: { type: string, format: date-time }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/scans:
    post:
      summary: Create a scan
      description: |
        Creates a regulatory scan.

        **Required parameters** (request body):
        - `jurisdictions` — non-empty array, ≤20 entries, each ≤100 chars. The
          authoritative list of IDs is at `GET /v1/meta/jurisdictions`.
        - `areas` — non-empty array, ≤20 entries, each ≤200 chars. List at
          `GET /v1/meta/areas`.
        - `horizon` — one of `3, 6, 12, 18, 24, 36` (months).

        **Optional parameters** with notable interactions:
        - `engine` — defaults to `v4`. Valid values at `GET /v1/meta/engines`
          (`v4`, `v4.5-alpha`, `v4.5-beta`, `council`). The retired engines
          `v1`, `v2`, `v3` and `admiral` are rejected with 422
          `engine_deprecated`. `council` runs the Analyst Council: two fixed
          seats (V4 · Opus 4.6 and V4.5β · Opus 5) in parallel, a
          deterministic merge, and an Opus-4.6 Writer composing one definitive
          briefing; `model` does not apply (422 `council_model_fixed` unless
          omitted or `opus`), and the run persists the composite plus two
          seat snapshots. Council runs are long (~15 minutes) — use
          `mode=async`.
        - `model` / `searchModel` — see `GET /v1/meta/models`. `haiku` is
          rejected on verifier-aware paths (`v4` with `realist`/`auditor`, or
          `v4.5-*`).
        - `verificationMode` — only valid when `engine` is `v4`, `v4.5-alpha`
          or `v4.5-beta`. Setting it auto-enables `realist=true` and
          `auditor=true` if those are unset.
        - `realist` / `auditor` — boolean; only valid on verifier-aware
          engines.
        - `auditorModel` — `luna` (default) or `sonnet` (legacy/rollback).
        - `fetchProvider` — `crw` or `firecrawl`; only valid when `engine`
          is `v4.5-beta`. Omitted: the deployment's `FETCH_PROVIDER` dial,
          else `crw`.
        - `searchProvider` — `serper` or `serpapi`; the `search_web` backend,
          only valid when `engine` is `v4.5-beta`. Omitted: the deployment's
          `SEARCH_PROVIDER` dial, else `serper`.
        - `verifyProvider` — `firecrawl` or `crw`; the page-read backend for
          event verification in the QA tail, valid on every engine. Omitted:
          `crw` for a scan carrying `fetchProvider: crw` (any engine), or for
          a `v4.5-beta` scan whose page reads resolve to `crw` (the
          `fetchProvider` field, else the `FETCH_PROVIDER` dial, else the
          `crw` default); else the `DRIFTER_SCRAPE_PROVIDER` dial, else
          `firecrawl`.

        **Modes** (query):
        - `mode=sync` (default): blocks up to 120 seconds. If the engine
          finishes inside that window, returns 200 + full snapshot envelope.
          If not, returns 202 with `scan_id` and the work continues in the
          background.
        - `mode=async`: returns 202 immediately; poll `status_url` or
          subscribe to `stream_url`.

        **Idempotency.** Pass `Idempotency-Key` to make retries safe. Replays
        within 24 hours that match the original body fingerprint return the
        cached response (`Idempotency-Replayed: true`); mismatched bodies
        return 409.

        **Failure modes.**
        - `402 budget_exhausted` — Clerk budget pool depleted.
        - `409 idempotency_key_in_use` — replayed key with a different body.
        - `409 idempotency_in_progress` — first request with this key is
          still running.
        - `422 validation_error` — see `errors[]` for per-field reasons.
        - `429` — per-bucket rate limit; honour `Retry-After`.
        - `503 engine_unavailable` — selected engine not loaded in this build.
      parameters:
        - name: mode
          in: query
          description: '`sync` (default) waits up to 120s before returning 202; `async` returns 202 immediately.'
          schema: { type: string, enum: [sync, async], default: sync }
        - name: Idempotency-Key
          in: header
          required: false
          description: 1-255 ASCII chars. 24-hour replay window.
          schema: { type: string, maxLength: 255, minLength: 1 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScanConfig' }
            examples:
              minimal:
                summary: Minimal config — engine defaults to v4, sync mode
                value:
                  jurisdictions: [UK, EU]
                  areas: [AML / KYC, Sanctions]
                  horizon: 12
              verifierAware:
                summary: Verifier-aware v4.5-alpha
                value:
                  jurisdictions: [US]
                  areas: [Capital Markets]
                  horizon: 6
                  engine: v4.5-alpha
                  model: sonnet
                  verificationMode: in-analyst
      responses:
        '200':
          description: Scan completed within sync ceiling.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scan_id: { type: string, format: uuid }
                  status: { type: string, enum: [completed] }
                  snapshot_id: { type: string, format: uuid }
                  snapshot: { $ref: '#/components/schemas/SnapshotEnvelope' }
        '202':
          description: Scan queued or exceeded sync ceiling.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanQueued' }
        '402':
          $ref: '#/components/responses/Problem'
        '409':
          $ref: '#/components/responses/Problem'
        '422':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'
        '503':
          $ref: '#/components/responses/Problem'

  /v1/scans/estimate:
    post:
      summary: Estimate cost for a scan config
      description: |
        Returns a cost envelope (low / expected / high in cents) for the
        supplied scan config without actually queueing a scan. Counts of
        jurisdictions and areas are what the estimator buckets on; names are
        not consulted, so callers may pass stub strings if they only want a
        ballpark by count. Unlike `POST /v1/scans`, `horizon` is optional
        here (validated only when supplied).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScanEstimateRequest' }
      responses:
        '200':
          description: Cost envelope.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanEstimate' }
        '422':
          $ref: '#/components/responses/Problem'

  /v1/scans/{id}:
    get:
      summary: Poll a scan
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Scan status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanStatus' }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/scans/{id}/cancel:
    post:
      summary: Cancel a running scan
      description: |
        Marks the scan `cancelled` and notifies SSE listeners. Already-terminal
        scans return 409. Best-effort: if the engine call is mid-flight, it
        continues in-process but its terminal DB write is suppressed.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  status: { type: string, enum: [cancelled] }
        '404':
          $ref: '#/components/responses/Problem'
        '409':
          $ref: '#/components/responses/Problem'

  /v1/scans/{id}/stream:
    get:
      summary: Stream scan progress (SSE)
      description: |
        Server-Sent Events channel. Event types:
        - `progress` — `{ phase, percent, message }` per phase advance
        - `complete` — `{ data: <snapshot envelope>, snapshotId }`
        - `error` — `{ error }` terminal
        A late `complete` event read from persistence uses the same public,
        sanitizer-backed and legacy-Auditor-backfilled projection as GET
        `/v1/snapshots/{id}`. Live and synchronous completions retain their
        in-memory prose citation markers. Private Auditor failure diagnostics
        are omitted from every completion path.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: text/event-stream
          content: { text/event-stream: {} }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots:
    get:
      summary: List snapshots
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: jurisdiction, in: query, schema: { type: string } }
        - { name: area, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Snapshots list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SnapshotListItem' }
                  pagination:
                    type: object
                    properties:
                      limit: { type: integer }
                      offset: { type: integer }
                      total: { type: integer }

  /v1/snapshots/{id}:
    get:
      summary: Retrieve full snapshot envelope
      parameters:
        - $ref: '#/components/parameters/PathId'
        - name: language
          in: query
          description: Optional content locale to project through the snapshot read composer when a complete owned translation is available.
          schema: { type: string, enum: [en, fr, de, es, it, zh, ja, simple] }
      responses:
        '200':
          description: Snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  title: { type: string }
                  config: { type: object }
                  data: { $ref: '#/components/schemas/SnapshotEnvelope' }
                  cost_cents:
                    type: number
                    nullable: true
                    description: Reconciled ledger actual when available (sub-cent float, e.g. 131.2245), else the original whole-cent estimate.
                  created_at: { type: string, format: date-time }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots/{id}/items:
    get:
      summary: Marquee — items array
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: 'items (`data: null` when the snapshot has no items key); inert inline citation-marker tokens are omitted from plain-prose fields'
          content: { application/json: { schema: { type: object, properties: { data: { type: array, nullable: true } } } } }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots/{id}/trends:
    get:
      summary: Marquee — trends array
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: 'trends (`data: null` when the snapshot has no trends key); inert inline citation-marker tokens are omitted from plain-prose fields'
          content: { application/json: { schema: { type: object, properties: { data: { type: array, nullable: true } } } } }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots/{id}/executive-summary:
    get:
      summary: Marquee — executive summary block
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: 'executive_summary (`data: null` when absent); inert inline citation-marker tokens are omitted from plain-prose fields'
          content: { application/json: { schema: { type: object, properties: { data: { type: object, nullable: true } } } } }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots/{id}/executive-narrative:
    get:
      summary: Marquee — executive narrative prose
      parameters:
        - $ref: '#/components/parameters/PathId'
        - { name: language, in: query, schema: { type: string, default: en } }
      responses:
        '200':
          description: narrative
          content:
            application/json:
              schema:
                type: object
                properties:
                  executive_narrative:
                    type: string
                    description: Plain narrative prose; inert inline citation-marker tokens are omitted.
                  language: { type: string }
        '404':
          description: |
            Snapshot not found (`not_found`), no narrative on the snapshot
            (`not_found`), or no translation for the requested language
            (`translation_not_available`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /v1/snapshots/{id}/drift:
    get:
      summary: Marquee — drift overlay
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: 'drift overlay or { available: false }'
          content: { application/json: { schema: { type: object } } }
        '404':
          $ref: '#/components/responses/Problem'

  /v1/snapshots/{id}/briefing:
    get:
      summary: Marquee — sub-editor briefing block
      description: |
        The snapshot's editorial furniture — headline, standfirst, priority
        labels, tight bottom line, pull quote, and sentiment — as written by
        the sub-editor at scan time. Snapshots that predate the sub-editor
        (or whose pass failed open) return `{ available: false }` with 200;
        404 is reserved for the snapshot itself being missing.
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: '`{ available: true, ...SnapshotBriefing }` or `{ available: false }`'
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                additionalProperties: true
                description: >-
                  When available is true, the remaining properties are the
                  SnapshotBriefing fields.
        '404':
          $ref: '#/components/responses/Problem'

  /v1/exports:
    post:
      summary: Create an export job
      description: |
        Queues an export against a snapshot you own. Exports run async on a
        worker — poll `status_url`, then fetch `download_url` once
        `status=completed`.

        **The recipe:**
        1. `GET /v1/meta/export-types` — each row carries `provider`,
           `artifact_type`, and `options_schema` (field → type/enum/default).
        2. POST here with `snapshot_id` + the `provider`/`artifact_type` pair
           and any `options` from that schema.
        3. Poll `GET /v1/exports/{id}` until `completed`; download.

        **Required body fields:**
        - `snapshot_id` — UUID of a snapshot owned by the caller (from
          `POST /v1/scans` or `GET /v1/snapshots`).
        - `provider` + `artifact_type` — must be a registered pair
          (422 with the valid list otherwise).

        **`options` is validated, not a free-for-all.** All option values are
        strings. Unknown option names and out-of-vocabulary enum values return
        422 with did-you-mean hints and the full valid list. The authoritative
        per-type schema is in `GET /v1/meta/export-types` → `options_schema`.

        **targetLanguage compatibility note (July 2026; `simple` added
        August 2026).** This option is now a closed product-locale enum:
        `en, fr, de, es, it, zh, ja, simple` — `simple` is Simplified English,
        a plain-language register of English rather than a translation. Earlier
        API-v1 metadata described an open BCP-47-like value. Unsupported values
        now return 422 `unsupported_locale`. `targetLanguage` records requested
        output intent; consumers must inspect `effective_locale` and
        `locale_provenance` for the language actually produced.

        **styleSlug availability note (August 2026).** The imagery `styleSlug`
        enums (`gemini-infographic:*`, `openai-infographic:*`) can be reduced
        at any time by platform administration: a style withdrawn from the
        catalogue disappears from `GET /v1/meta/export-types` →
        `options_schema` and returns 422 `invalid_value` here. Always read the
        current enum from the meta endpoint rather than caching it.

        Full map:

        | provider:artifact_type | options |
        |---|---|
        | `internal-pdf:pdf`, `internal-csv:csv` | none |
        | `internal-briefing-pdf:briefing-pdf` | `targetLanguage` |
        | `internal-pptx:slide-deck` | `theme` (light/dark), `editorialModel` (**deprecated — accepted but ignored**), `targetLanguage` |
        | `internal-tearsheet:tearsheet` | `editorialModel` (**deprecated — accepted but ignored**), `targetLanguage` |
        | `frontend-slides:slide-web` | `styleSlug` (dark-editorial/light-consulting), `targetLanguage` |
        | `gemini-infographic:*` | `styleSlug` (visual styles; see meta), `planModel` (sonnet5/sonnet/opus/opus48), `targetLanguage` |
        | `openai-infographic:*` | `styleSlug` (visual styles; see meta), `targetLanguage` |
        | `notebooklm:audio` | `audio_format` (DEBATE/DEEP_DIVE/BRIEF/CRITIQUE), `audio_length` (SHORT/DEFAULT/LONG), `instructions`, `targetLanguage` |
        | `notebooklm:infographic` | `style`, `detail_level`, `orientation`, `instructions`, `targetLanguage` |
        | `notebooklm:slide-detailed` | `slide_length` (SHORT/DEFAULT), `instructions`, `targetLanguage` |
        | `notebooklm:video-explainer` | `instructions`, `targetLanguage` |
        | `notebooklm:video` | `video_format` (CINEMATIC), `instructions`, `targetLanguage` |
        | `elevenlabs-briefing:podcast-briefing` | `voiceId`, `modelId` (alias `model_id`), `targetLanguage` |
        | `elevenlabs-discussion:podcast-discussion` | `modelId` (alias `model_id`), `targetLanguage` |

        Note: free-text `instructions` are subject to the account's
        artefact-customisation gate — when customisation is disabled for your
        account, admin defaults are substituted server-side.

        **Idempotency.** Send `Idempotency-Key` to make retries safe; same
        24h window and 409 codes as `/v1/scans`.

        **Failure modes.**
        - `404 not_found` — snapshot_id not owned by caller.
        - `409 idempotency_key_in_use` / `idempotency_in_progress` — see
          Idempotency above.
        - `422 validation_error` — missing field, unknown
          `provider:artifact_type` combination, or invalid `options`
          (each error names the offending field and the valid vocabulary).
        - `503` — database unavailable.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: 1-255 ASCII chars. 24-hour replay window.
          schema: { type: string, maxLength: 255, minLength: 1 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExportCreate' }
            examples:
              pdf:
                summary: Internal PDF (no options)
                value:
                  snapshot_id: 00000000-0000-0000-0000-000000000000
                  provider: internal-pdf
                  artifact_type: pdf
              slideDeck:
                summary: PPTX slide deck, light theme
                value:
                  snapshot_id: 00000000-0000-0000-0000-000000000000
                  provider: internal-pptx
                  artifact_type: slide-deck
                  options:
                    theme: light
              geminiInfographic:
                summary: Gemini infographic in a riso style
                value:
                  snapshot_id: 00000000-0000-0000-0000-000000000000
                  provider: gemini-infographic
                  artifact_type: infographic-custom
                  options:
                    styleSlug: riso-tritone-gem
                    planModel: opus
              notebooklmPodcast:
                summary: NotebookLM podcast — deep-dive, long
                value:
                  snapshot_id: 00000000-0000-0000-0000-000000000000
                  provider: notebooklm
                  artifact_type: audio
                  options:
                    audio_format: DEEP_DIVE
                    audio_length: LONG
                    instructions: Focus on enforcement-action implications.
              elevenLabsDiscussion:
                summary: ElevenLabs two-voice discussion in French
                value:
                  snapshot_id: 00000000-0000-0000-0000-000000000000
                  provider: elevenlabs-discussion
                  artifact_type: podcast-discussion
                  options:
                    targetLanguage: fr
      responses:
        '202':
          description: Job queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  export_job_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued, processing, completed, failed, undelivered, cancelled] }
                  status_url:
                    type: string
                    example: /v1/exports/abc-123
                  download_url:
                    type: string
                    nullable: true
                    description: Populated only when status is `completed`.
                    example: /v1/exports/abc-123/download
        '404':
          $ref: '#/components/responses/Problem'
        '409':
          $ref: '#/components/responses/Problem'
        '422':
          $ref: '#/components/responses/Problem'
        '503':
          $ref: '#/components/responses/Problem'
    get:
      summary: List export jobs for the caller
      parameters:
        - { name: snapshot_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string }, description: 'Comma-separated list (e.g. queued,processing,completed). Unknown statuses return 422.' }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
      responses:
        '200':
          description: jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ExportJob' }
        '422':
          $ref: '#/components/responses/Problem'

  /v1/exports/{id}:
    get:
      summary: Poll an export job
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: job status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }
        '404':
          $ref: '#/components/responses/Problem'
    delete:
      summary: Delete an export job (and its retained artifact bytes)
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '204':
          description: Deleted.
        '404':
          $ref: '#/components/responses/Problem'

  /v1/exports/{id}/download:
    get:
      summary: Stream the completed artifact
      description: |
        Returns the raw artifact bytes. Inline disposition for audio/video/PDF/
        image MIME types so embeddable players can fetch the same URL; otherwise
        attachment disposition. Audio + video support HTTP range requests.
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: The artifact.
          content:
            application/octet-stream: {}
        '206':
          description: Partial content (range request).
        '404':
          $ref: '#/components/responses/Problem'
        '409':
          $ref: '#/components/responses/Problem'
        '410':
          $ref: '#/components/responses/Problem'
        '416':
          $ref: '#/components/responses/Problem'

  /v1/meta/engines:
    get:
      summary: List supported research engines
      responses:
        '200':
          description: Engines + valid horizons.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        label: { type: string }
                        description: { type: string }
                  horizons:
                    type: array
                    items: { type: integer }

  /v1/meta/models:
    get:
      summary: List provider models available for scan configs
      responses:
        '200':
          description: Models grouped by provider.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        provider: { type: string }
                        label: { type: string }
                        configured: { type: boolean }
                        models:
                          type: array
                          items:
                            type: object
                            properties:
                              id: { type: string }
                              label: { type: string }
                              role: { type: string }
                              tier: { type: string, nullable: true }
                              model_id: { type: string }
                              pro_variant:
                                type: object
                                nullable: true
                                properties:
                                  id: { type: string }
                                  label: { type: string }
                                  model_id: { type: string }

  /v1/meta/jurisdictions:
    get:
      summary: List supported jurisdictions (with regulators)
      responses:
        '200':
          description: Jurisdictions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        label: { type: string }
                        regulators:
                          type: array
                          items: { type: string }

  /v1/meta/areas:
    get:
      summary: List supported regulatory areas
      responses:
        '200':
          description: Areas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        label: { type: string }

  /v1/meta/export-types:
    get:
      summary: List supported export (provider, artifact_type) pairs
      responses:
        '200':
          description: Export types.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string }
                        label: { type: string }
                        provider: { type: string }
                        artifact_type: { type: string }
                        group: { type: string }
                        provider_label: { type: string }
                        requires_provider: { type: string, nullable: true }
                        has_options: { type: boolean }
                        options_schema:
                          type: object
                          description: |
                            Option vocabulary for POST /v1/exports `options`,
                            keyed by field name. Each descriptor carries
                            `type` (string), optional `enum` (valid values),
                            `default`, `description`, and `maxLength`.
                            Empty object = this type takes no options.
                          additionalProperties:
                            type: object
                            properties:
                              type: { type: string }
                              enum: { type: array, items: { type: string } }
                              default: { type: string }
                              description: { type: string }
                              maxLength: { type: integer }

  /v1/usage/budget:
    get:
      summary: Current account budget + spend
      description: |
        Returns the budget pool the caller's account is constrained by. Same
        accounting path the dashboard and `POST /v1/scans` (402) consult.
        Authenticated via bearer or Clerk session.
      responses:
        '200':
          description: Budget snapshot.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BudgetSnapshot' }

  /v1/usage:
    get:
      summary: Usage totals + buckets
      description: |
        Defaults to last 30 days, grouped by day. Authenticated via bearer
        (API consumers) or Clerk session (dashboard). `from` must not be
        after `to` (422 otherwise). When `group_by=key`, each bucket also
        carries `key_name`, `key_prefix`, and `key_revoked` so callers can
        attribute usage without a second lookup.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: group_by, in: query, schema: { type: string, enum: [day, endpoint, key], default: day } }
      responses:
        '200':
          description: usage
          content:
            application/json:
              schema:
                type: object
                properties:
                  totals:
                    type: object
                    properties:
                      request_count: { type: integer }
                      scan_count: { type: integer }
                      export_count: { type: integer }
                      error_count: { type: integer, description: Requests with status >= 400 }
                      total_cost_cents: { type: number, description: May carry sub-cent precision from reconciled scan actuals. }
                      avg_latency_ms: { type: integer, nullable: true }
                  buckets:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string, description: "Day (YYYY-MM-DD), 'METHOD /path', or api_key id depending on group_by" }
                        request_count: { type: integer }
                        scan_count: { type: integer }
                        export_count: { type: integer }
                        error_count: { type: integer }
                        cost_cents: { type: number, description: May carry sub-cent precision from reconciled scan actuals. }
                        avg_latency_ms: { type: integer, nullable: true }
                        key_name: { type: string, nullable: true, description: Present when group_by=key }
                        key_prefix: { type: string, nullable: true, description: Present when group_by=key }
                        key_revoked: { type: boolean, nullable: true, description: Present when group_by=key }
                  filters:
                    type: object
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                      group_by: { type: string }
        '422':
          $ref: '#/components/responses/Problem'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: regsn_live_<32 base62 chars>

  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Problem:
      description: Error
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        code: { type: string }
        request_id: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              code: { type: string }
              message: { type: string }

    ApiKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key_prefix: { type: string, example: "regsn_live_x9k…" }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
        project_id: { type: string, nullable: true }

    ApiKeyWithRaw:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          properties:
            key:
              type: string
              description: Raw key — returned exactly once. Store securely.
              example: regsn_live_aBcD1234eFgH5678iJkL9012mNoP3456

    ScanConfig:
      type: object
      required: [jurisdictions, areas, horizon]
      description: |
        Scan request body. Mirrors the wizard config used by the regsn.app UI.
        Discoverable enums live behind `/v1/meta/*`.
      properties:
        jurisdictions:
          type: array
          description: Jurisdiction IDs from GET /v1/meta/jurisdictions.
          items: { type: string, maxLength: 99 }
          example: [UK, EU]
          minItems: 1
          maxItems: 20
        areas:
          type: array
          description: Area labels from GET /v1/meta/areas.
          items: { type: string, maxLength: 199 }
          example: [AML / KYC, Sanctions]
          minItems: 1
          maxItems: 20
        horizon:
          type: integer
          description: Months of forward horizon.
          enum: [3, 6, 12, 18, 24, 36]
          example: 12
        engine:
          type: string
          enum: [v4, v4.5-alpha, v4.5-beta]
          default: v4
          description: |
            Research engine to run. Verifier-aware engines (`v4`, `v4.5-alpha`,
            `v4.5-beta`) accept `verificationMode`, `realist`, `auditor`.
            The retired engines `v1`, `v2`, `v3` and `admiral` are rejected
            with 422 `engine_deprecated`.
        model:
          type: string
          example: sonnet
          description: Provider model ID; see GET /v1/meta/models. `haiku` is rejected on verifier-aware paths.
        searchModel:
          type: string
          example: sonnet
          description: Provider model used for the search pass.
        verificationMode:
          type: string
          enum: [in-analyst]
          nullable: true
          description: Only valid when `engine` is `v4`, `v4.5-alpha` or `v4.5-beta`.
        realist:
          type: boolean
          description: Verifier-aware engines only.
        auditor:
          type: boolean
          description: Verifier-aware engines only.
        auditorModel:
          type: string
          enum: [luna, sonnet]
          description: >-
            Auditor model. `luna` (GPT-5.6 Luna, reasoning effort xhigh) is
            the default since Aug 2026; `sonnet` (Sonnet 4.6) remains valid
            as the legacy/rollback value. The audited sidecar records which
            model ran in `auditor._meta.auditorModel`.
        fetchProvider:
          type: string
          enum: [firecrawl, crw]
          nullable: true
          description: |
            Page-fetch backend for the `fetch_url` tool. Only valid when
            `engine` is `v4.5-beta`. When omitted the server resolves the
            deployment's `FETCH_PROVIDER` dial, else `crw`; the resolved
            value is recorded on the snapshot's scan config.
        searchProvider:
          type: string
          enum: [serper, serpapi]
          nullable: true
          description: |
            Google-SERP backend for the `search_web` tool. Only valid when
            `engine` is `v4.5-beta`. When omitted the server resolves the
            deployment's `SEARCH_PROVIDER` dial, else `serper`; the resolved
            value is recorded on the snapshot's scan config.
        verifyProvider:
          type: string
          enum: [firecrawl, crw]
          nullable: true
          description: |
            Page-read backend for event verification in the QA tail (the
            Drifter station's setting). Valid on every engine. When omitted
            the server falls through in order: `crw` for a scan carrying
            `fetchProvider: crw` on any engine, or for a `v4.5-beta` scan
            whose page reads resolve to `crw` (the `fetchProvider` field,
            else the deployment's `FETCH_PROVIDER` dial, else the `crw`
            default); else the deployment's `DRIFTER_SCRAPE_PROVIDER` dial,
            else `firecrawl`. The resolved value is recorded on the
            snapshot's scan config.

    ScanEstimateRequest:
      type: object
      required: [jurisdictions, areas]
      description: |
        Body for POST /v1/scans/estimate. Same shape as ScanConfig, but
        `horizon` is optional (validated only when supplied) and the
        estimator additionally accepts provider hints that feed the
        fingerprint's provider mix.
      properties:
        jurisdictions:
          type: array
          items: { type: string }
          minItems: 1
          description: Counted, not name-matched — stub strings are fine.
        areas:
          type: array
          items: { type: string }
          minItems: 1
          description: Counted, not name-matched — stub strings are fine.
        horizon:
          type: integer
          enum: [3, 6, 12, 18, 24, 36]
        engine:
          type: string
          enum: [v4, v4.5-alpha, v4.5-beta]
          default: v4
        seekerProvider:
          type: string
          description: Provider hint for the fingerprint's provider mix.
        analystProvider:
          type: string
          description: Provider hint for the fingerprint's provider mix.
        translatorProvider:
          type: string
          description: Provider hint for the fingerprint's provider mix.

    ScanQueued:
      type: object
      properties:
        scan_id: { type: string, format: uuid }
        status: { type: string, enum: [running] }
        status_url: { type: string }
        stream_url: { type: string }
        message: { type: string }

    ScanEstimate:
      type: object
      properties:
        low_cents: { type: number, nullable: true, description: P10 of matched historical actuals (may be fractional cents). }
        expected_cents: { type: number, nullable: true, description: P50 (tiers 1-2) or similarity-weighted mean (tier 3). }
        high_cents: { type: number, nullable: true, description: P90 of matched historical actuals (may be fractional cents). }
        confidence:
          type: string
          enum: [high, medium, low, cold_start]
        tier:
          type: integer
          enum: [1, 2, 3]
          nullable: true
          description: |
            Bucket the estimator landed in. 1 = exact-match window, 2 = relaxed
            one dimension at a time, 3 = global similarity-weighted mean.
            null on cold-start fallback.
        sample_size: { type: integer }
        fingerprint:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Structured fingerprint of the supplied scan config. Stable across
            calls with equivalent counts/providers/horizon/engine; used to look
            up matching historical actuals.
          properties:
            scan_mode: { type: string }
            engine_version: { type: string }
            jurisdiction_bucket: { type: string }
            area_bucket: { type: string }
            enabled_exports_count: { type: integer }
            provider_mix_hash: { type: string }
            date_range_bucket: { type: string }
        estimator_version: { type: string, example: v2.0 }
        engine: { type: string }

    ExportCreate:
      type: object
      required: [snapshot_id, artifact_type, provider]
      properties:
        snapshot_id:
          type: string
          format: uuid
          description: Snapshot to export. Must be owned by the caller.
        provider:
          type: string
          description: Provider key; see GET /v1/meta/export-types.
          example: internal-pdf
        artifact_type:
          type: string
          description: 'Artifact key, valid only paired with the matching provider.'
          example: pdf
        options:
          type: object
          description: |
            Per-type option fields — validated against the registry
            (GET /v1/meta/export-types → options_schema). All values must be
            strings. Unknown fields or out-of-vocabulary values return 422
            with did-you-mean hints.
          additionalProperties: { type: string }

    BudgetSnapshot:
      type: object
      properties:
        allowed: { type: boolean }
        budget_cents:
          type: integer
          nullable: true
          description: null means no budget cap.
        spent_cents: { type: number, description: May carry sub-cent precision from reconciled ledger actuals. }
        remaining_cents:
          type: number
          nullable: true
          description: null means unlimited.
        role: { type: string, enum: [user, admin] }
        message: { type: string }

    ExportJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued, processing, completed, failed, undelivered, cancelled] }
        artifact_type: { type: string }
        provider: { type: string }
        snapshot_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        cost_cents: { type: number, nullable: true }
        error: { type: string, nullable: true }
        result_mime: { type: string, nullable: true }
        result_filename: { type: string, nullable: true }
        result_size_bytes: { type: integer, nullable: true }
        download_url:
          type: string
          nullable: true
          description: Populated only when status is `completed`.

    ScanStatus:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [running, completed, failed, cancelled] }
        progress:
          type: object
          properties:
            phase: { type: string }
            percent: { type: integer }
            message: { type: string }
        snapshot_id: { type: string, format: uuid, nullable: true }
        cost_cents: { type: number, nullable: true, description: Reconciled ledger actual when available (sub-cent float), else the whole-cent estimate. Null until the scan completes. }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        error: { type: string, nullable: true }

    SnapshotListItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        created_at: { type: string, format: date-time }
        item_count: { type: integer }
        trend_count: { type: integer }
        jurisdiction_count: { type: integer }
        cost_cents: { type: number, nullable: true, description: Legacy whole-cent estimate. }
        actual_cost_cents: { type: number, nullable: true, description: Reconciled ledger actual — sub-cent float (e.g. 131.2245). }

    SnapshotEnvelope:
      type: object
      description: |
        The snapshot `data` JSONB envelope projected from the research engine
        and post-scan QA tail. Open shape — keys beyond those documented here
        may appear depending on engine and config. Inert inline citation-marker
        tokens are omitted from every plain-prose field, including materialised
        translations. Structured Auditor, source, citation, URL and identity
        sidecars retain their schema; operational Auditor failure excerpts are
        confined to the private audit record and are never included here.
      required: [items, trends, executive_summary, _meta]
      properties:
        items:
          type: array
          description: Regulatory items emitted by the Analyst, with inert inline citation-marker tokens omitted from plain-prose fields.
          items: { type: object, additionalProperties: true }
        trends:
          type: array
          description: Cross-item trends, with inert inline citation-marker tokens omitted from plain-prose fields.
          items: { type: object, additionalProperties: true }
        executive_summary:
          type: object
          additionalProperties: true
          description: Executive-summary prose with inert inline citation-marker tokens omitted.
        executive_narrative:
          type: string
          nullable: true
          description: English executive narrative prose. Public API responses omit inert inline citation-marker tokens; structured Auditor metadata remains in `auditor`.
        _meta:
          type: object
          additionalProperties: true
          description: Engine/run metadata (engine, model, searches, ...).
        warnings:
          type: array
          items: { type: string }
          description: Non-fatal engine warnings (truncation recovery etc.).
        auditor:
          type: object
          nullable: true
          additionalProperties: true
          description: Auditor sidecar — present on verifier-aware runs with `auditor` enabled. Operational `_meta.failures` evidence is private to `audit_records` and is omitted.
        _briefing:
          nullable: true
          description: Sub-editor briefing block (post-scan QA tail). See SnapshotBriefing.
          allOf:
            - $ref: '#/components/schemas/SnapshotBriefing'
        translations:
          type: object
          nullable: true
          description: Translated blocks keyed by language code (e.g. `fr`), each carrying `executive_narrative` and friends with inert inline citation-marker tokens omitted from plain prose.
          additionalProperties: { type: object }
        driftOverlay:
          type: object
          nullable: true
          additionalProperties: true
          description: 'Legacy drift overlay (older snapshots only) — served by GET /v1/snapshots/{id}/drift, which returns `{ available: false }` when absent.'
      additionalProperties: true

    SnapshotBriefing:
      type: object
      description: |
        Sub-editor briefing block — the editorial furniture written by the
        post-scan QA tail (server/subeditor.js) and spread verbatim onto the
        envelope as `_briefing`. Seven fields total. The three live fields
        (`pull_quote`, `sentiment_label`, `sentiment_rationale`) are always
        present when the block exists; the four A-fields
        (`briefing_headline`, `briefing_standfirst`, `priorities`,
        `bottom_line_tight`) are validated per-field and ABSENT on validation
        failure — clients render deterministic fallbacks, never partial junk.
      required: [pull_quote, sentiment_label, sentiment_rationale]
      properties:
        briefing_headline:
          type: string
          description: Editorial headline, 6 words or fewer, ends with a full stop. Absent when validation failed.
        briefing_standfirst:
          type: string
          description: One-sentence standfirst under the headline. Absent when validation failed.
        priorities:
          type: array
          description: |
            Exactly one entry per executive-summary priority, in the same
            order (1:1 with the briefing's numbered Priority sections).
            Within an aligned array, each field is individually optional — a
            bad label or tag is omitted from that entry only. The whole array
            is ABSENT when it could not be aligned with the source priorities.
          items:
            type: object
            properties:
              short_label:
                type: string
                description: Contents-list label, 1-3 words (e.g. "MiCA enforcement").
              tags:
                type: string
                description: 'Single tag string in the exact format "JURISDICTION · INSTRUMENT" (middle-dot separator, e.g. "EU · MiCA"). Singular value despite the plural key.'
            additionalProperties: true
        bottom_line_tight:
          type: string
          description: The briefing's "so what" in 1-2 sentences; closes the page. Absent when validation failed.
        pull_quote:
          type: string
          description: Lean 2-sentence executive pull-quote (~40 words) lifted from the body of the analyst narrative.
        sentiment_label:
          type: string
          enum: [Tightening, Stable, Easing, Mixed, Volatile]
          description: One-word overall regulatory-posture read.
        sentiment_rationale:
          type: string
          description: One sentence (max 30 words) explaining the sentiment label.
      additionalProperties: true
