openapi: 3.1.0
info:
  title: OilFlow Compliance API
  version: v1-beta
  description: |
    Compliance-tech infrastructure for emerging-market physical commodity trade.
    Four B2B SaaS products: Regulatory Matrix API, Counterparty KYC-as-API,
    Scam Cluster Intelligence Feed, and Trade-Compliance Workflow Suite.

    Authentication: pass your API key as either `Authorization: Bearer <key>`
    or `X-Api-Key: <key>`. Production keys begin with `oilflow_`.

    Audit trail: every request leaves an append-only row in `api_request_log`.
    Customers can request their own log via `audit-log@oilflow.us`. Banks use
    this trail as compliance evidence during their own regulatory audits.

    Rate limits: 60 req/min (regulatory + cluster list), 120 req/min (cluster
    check), 30 req/min (KYC screen). Per-key daily quotas are configured at
    the time of key issuance.
  contact:
    name: OilFlow API Support
    email: api@oilflow.us
    url: https://oilflow.us/api-docs
  license:
    name: Proprietary
    url: https://oilflow.us/terms
  termsOfService: https://oilflow.us/terms
servers:
  - url: https://oilflow.us
    description: Production
security:
  - bearerAuth: []
  - apiKeyAuth: []
tags:
  - name: Regulatory Matrix API
    description: SKU #1 — 235-jurisdiction product-tradability + payment-term + sanctions rules matrix
  - name: Counterparty KYC-as-API
    description: SKU #2 — 7-step KYC pipeline with 7-list sanctions screening + PEP across 235 jurisdictions
  - name: Scam Cluster Intelligence Feed
    description: SKU #3 — Real-time feed of verified-fraudulent counterparty clusters
  - name: Webhooks
    description: Cross-SKU outbound webhook subscriptions, event queue, and DLQ replay.
  - name: Regulator Reports
    description: Six regulator-ready report templates (FinCEN SAR, FATF Rec 10, FCA SYSC 18, MAS Notice 626, OFSI Annual, FFIEC BSA).
  - name: Adverse Media
    description: Multilingual adverse-media monitoring for registered entities (Arabic, Urdu, Mandarin, Russian, Spanish, French, Swahili, English).
  - name: UBO Graph
    description: Beneficial-owner traversal + shell-detection across OpenCorporates and national registries.
  - name: LC Validation
    description: UCP 600 discrepancy engine for Letter of Credit + invoice + Bill of Lading bundles.
  - name: Audit
    description: Cross-SKU append-only audit log export.
  - name: Sandbox
    description: Self-serve sandbox API keys for evaluation. Throwaway keys auto-expire after 7 days.
  - name: Pre-Deal Copilot
    description: SKU #5 — Front-office pre-deal clearance probability + restructure suggestion (RM-facing). Sub-30s verdict. Sells to RMs / brokers, not compliance.
  - name: Defense Ledger
    description: SKU #6 (Q1 2027) — Regulator-grade decision-level evidence packs with cryptographic MLRO attestation. SHA-256 binding, cross-regulator render (FinCEN SAR / FCA SYSC 18 / FATF Rec 10 / MAS Notice 626).
  - name: Verified Counterparty Network
    description: Counterparty-side opt-in for the two-sided OilFlow Verified network. Document-backed UBO disclosure + reference letters + +15 clearance lift on Pre-Deal verdicts.
paths:
  /api/v1/regulatory/countries:
    get:
      tags: [Regulatory Matrix API]
      summary: List all 235 jurisdictions in the regulatory matrix
      operationId: listRegulatedCountries
      responses:
        "200":
          description: Full list of regulated jurisdictions with per-product rules
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CountriesListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/regulatory/countries/{slug}:
    get:
      tags: [Regulatory Matrix API]
      summary: Get single jurisdiction by slug
      operationId: getCountryBySlug
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: URL-safe country slug, e.g. `pakistan`, `united-arab-emirates`
      responses:
        "200":
          description: Country found with full rule table
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SingleCountryResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/regulatory/check:
    get:
      tags: [Regulatory Matrix API]
      summary: Check if a product is tradeable between counterparties
      operationId: checkTradability
      parameters:
        - name: country
          in: query
          required: true
          schema:
            type: string
          description: Counterparty country name
          example: "Saudi Arabia"
        - name: product
          in: query
          required: true
          schema:
            type: string
          description: |
            Product category or keyword. Accepts the 5 canonical categories
            (crude, refined, lpg, lng, bitumen) and refined-product keywords
            (diesel, gasoline, gasoil, gas oil, fuel oil, fuel, naphtha, jet,
            kerosene). Unknown values return 400 invalid_param.
        - name: listing_type
          in: query
          required: false
          schema:
            type: string
            enum: [supply, demand]
            default: demand
          description: Direction — supply (export) or demand (import)
      responses:
        "200":
          description: Tradability determination with blockers (if any)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegulatoryCheckResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/regulatory/products:
    get:
      tags: [Regulatory Matrix API]
      summary: List product categories supported by the matrix
      operationId: listProducts
      responses:
        "200":
          description: Product categories with display labels
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductsListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/kyc/screen:
    post:
      tags: [Counterparty KYC-as-API]
      summary: Screen a counterparty for sanctions, scam-cluster, and regulatory risk
      operationId: screenCounterparty
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/KycScreenRequest"
      responses:
        "200":
          description: Screening verdict with per-check breakdown
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KycScreenResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/clusters:
    get:
      tags: [Scam Cluster Intelligence Feed]
      summary: List verified-fraudulent counterparty clusters
      operationId: listClusters
      parameters:
        - name: severity
          in: query
          required: false
          schema:
            type: string
            enum: [confirmed, likely, suspected]
        - name: country
          in: query
          required: false
          schema:
            type: string
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: ISO timestamp — only return clusters added after this time
      responses:
        "200":
          description: Cluster list with filters echoed back
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClusterListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/clusters/check:
    get:
      tags: [Scam Cluster Intelligence Feed]
      summary: Check if a specific entity matches the cluster blocklist
      operationId: checkCluster
      parameters:
        - name: entity
          in: query
          required: true
          schema:
            type: string
            minLength: 2
          description: |
            Entity name to check (company, person, alias). Must contain at
            least 2 alphanumeric characters after normalization (strip of
            non-alphanumeric and case-fold). Strict exact-match against the
            cluster blocklist plus alias exact-match; substring matching
            was removed in 2026-06 hardening to prevent broad fuzzy hits.
          example: "Simar Chahal"
        - name: country
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Match status with cluster details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClusterCheckResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/clusters/submit:
    post:
      tags: [Scam Cluster Intelligence Feed]
      summary: Submit a counterparty pitch for OilFlow investigation (cluster contribution flow)
      operationId: submitClusterContribution
      description: |
        Customer-contribution surface for SKU #3. Authenticated customers
        report a counterparty pitch they were approached with. Submission
        lands in `cluster_submissions` with `triage_status='pending'`.
        Operator triages within 72 hours; confirmed cases enter
        `broker_scam_blocklist` with provenance attribution to OilFlow's
        named investigator (submitter is never named in the public record).
        Submission is licensed exclusively to OilFlow per Terms § 18 / § 3A.
        Rate limit: 5 req/min per API key + 50 submissions/24h per submitter
        email. Min `pitch_summary` length 40 chars.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [submitter_email, entity_name, pitch_summary]
              properties:
                submitter_email:
                  type: string
                  format: email
                  description: Where to notify when triage completes. Never public.
                submitter_role:
                  type: string
                  description: e.g. "MLRO", "RM", "Independent investigator"
                submitter_affiliation:
                  type: string
                entity_name:
                  type: string
                  minLength: 2
                  maxLength: 300
                entity_country:
                  type: string
                entity_linkedin_url:
                  type: string
                  format: uri
                entity_email_domains:
                  type: array
                  items:
                    type: string
                pitch_summary:
                  type: string
                  minLength: 40
                  maxLength: 5000
                contact_channels:
                  type: array
                  items:
                    type: string
                  example: ["linkedin", "email", "whatsapp"]
                evidence_urls:
                  type: array
                  items:
                    type: string
                    format: uri
                evidence_notes:
                  type: object
                  description: |
                    Structured submitter-provided context. Common keys:
                    `claimed_principal`, `claimed_volume_mt`,
                    `warm_introducer_name`, `cargo_specs`.
      responses:
        "201":
          description: Submission accepted into the triage queue
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      request_id: { type: string }
                      status: { type: string, enum: [pending] }
                      review_eta_hours: { type: integer }
                      licensing_acknowledgment: { type: string }
                      next_steps: { type: string }
                      version: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          description: Daily submission limit reached for this submitter email
        "503":
          description: Schema not present in this environment (apply migration 166)

  /api/v1/clusters/submissions/{request_id}:
    get:
      tags: [Scam Cluster Intelligence Feed]
      summary: Check the triage status of a previously submitted cluster contribution
      operationId: getClusterSubmissionStatus
      parameters:
        - name: request_id
          in: path
          required: true
          schema:
            type: string
            pattern: '^sub_'
          example: "sub_lzpqr_abc123"
      responses:
        "200":
          description: Submission status + linked blocklist row when accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      request_id: { type: string }
                      triage_status:
                        type: string
                        enum: [pending, accepted, rejected, duplicate]
                      entity_name: { type: string }
                      submitted_at: { type: string, format: date-time }
                      triaged_at: { type: string, format: date-time, nullable: true }
                      triaged_by: { type: string, nullable: true }
                      linked_blocklist_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Populated when triage_status='accepted' or 'duplicate'
                      version: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Submitter / api key does not own this submission
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Counterparty KYC-as-API: on-demand re-screen (Lane 2A) ──────────
  /api/v1/kyc/rescreen:
    post:
      tags: [Counterparty KYC-as-API]
      summary: Queue an asynchronous re-screen of a previously registered entity
      operationId: rescreenKycDossier
      description: |
        Triggers an out-of-band re-run of the 7-step KYC pipeline for a
        previously registered `kyc_dossiers` row. Returns `202` with a
        `screening_run_id`. Subscribe to the `kyc.rescreen_completed`
        webhook event (and `kyc.match_detected` on a hit) to receive the
        outcome asynchronously.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RescreenRequest"
            examples:
              entity:
                summary: Re-screen Acme Trading FZE
                value:
                  entity_id: "9c5d6e7f-1a2b-4c3d-9e8f-aabbccddeeff"
      responses:
        "202":
          description: Re-screen queued — listen on the webhook event for completion
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RescreenAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Webhooks (Lane 2A) ────────────────────────────────────────────────
  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List your active webhook subscriptions
      operationId: listWebhookSubscriptions
      responses:
        "200":
          description: Subscriptions owned by the calling API key's member
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      operationId: createWebhookSubscription
      description: |
        On success the response contains the HMAC signing secret — store it,
        it will not be shown again. Rotation requires DELETE + POST.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookSubscriptionRequest"
            examples:
              slackAlerts:
                summary: Slack channel for KYC matches
                value:
                  url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
                  events: ["kyc.match_detected", "cluster.entity_severity_changed"]
                  description: "Compliance war-room Slack"
      responses:
        "201":
          description: Subscription created — secret returned once
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionCreated"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Deactivate a subscription
      operationId: deactivateWebhookSubscription
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Subscription deactivated (soft delete — events preserved)
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/webhooks/events:
    get:
      tags: [Webhooks]
      summary: Browse recent webhook delivery attempts
      operationId: listWebhookEvents
      parameters:
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [pending, delivering, delivered, failed, dlq]
        - in: query
          name: sub
          required: false
          schema:
            type: string
            format: uuid
          description: Filter to a single subscription
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Delivery attempts for the caller's subscriptions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEventsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/webhooks/events/{id}/replay:
    post:
      tags: [Webhooks]
      summary: Replay a DLQ'd webhook event
      operationId: replayWebhookEvent
      description: |
        Re-queues a `dlq` event back to `pending` with attempt count reset.
        Only `dlq` rows are replayable.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Event re-queued
        "409":
          description: Event is not in DLQ
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Regulator-Ready Reports (Lane 2B) ─────────────────────────────────
  /api/v1/reports:
    get:
      tags: [Regulator Reports]
      summary: List available regulator templates
      operationId: listReportTemplates
      responses:
        "200":
          description: Six templates (FinCEN SAR, FATF Rec 10, FCA SYSC 18, MAS 626, OFSI, FFIEC)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportTemplatesResponse"
    post:
      tags: [Regulator Reports]
      summary: Generate a regulator-ready report
      operationId: generateRegulatorReport
      description: |
        Pulls KYC dossier + sanctions screening + verification log evidence
        and renders an HTML artifact (PDF rendering happens out-of-band).
        The response includes a 10-minute signed download URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportRequest"
      responses:
        "201":
          description: Report ready
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportCreated"

  /api/v1/reports/{id}:
    get:
      tags: [Regulator Reports]
      summary: Fetch a generated report
      operationId: getRegulatorReport
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Report metadata + signed download URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportResponse"

  /api/v1/reports/{id}/audit:
    get:
      tags: [Regulator Reports]
      summary: JSON evidence trail for a report
      operationId: getReportAuditTrail
      description: |
        Returns `evidence_json` — the map of report fields to underlying
        `verification_log`, `sanctions_screening_log`, and
        `compliance_checks` row IDs. Banks present this to regulators
        when asked to substantiate prefilled answers.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Audit trail JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportAuditResponse"

  # ── Adverse Media (Lane 2C) ───────────────────────────────────────────
  /api/v1/adverse-media/entities:
    get:
      tags: [Adverse Media]
      summary: List monitored entities
      operationId: listMonitoredEntities
      responses:
        "200":
          description: Active monitored entities owned by the caller
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitoredEntitiesResponse"
    post:
      tags: [Adverse Media]
      summary: Register an entity for daily multilingual monitoring
      operationId: registerMonitoredEntity
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MonitoredEntityRequest"
      responses:
        "201":
          description: Entity registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitoredEntityCreated"

  /api/v1/adverse-media/entities/{id}:
    delete:
      tags: [Adverse Media]
      summary: Pause monitoring on an entity
      operationId: pauseMonitoredEntity
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Entity paused (soft delete — findings preserved)

  /api/v1/adverse-media/findings:
    get:
      tags: [Adverse Media]
      summary: List adverse media findings
      operationId: listAdverseMediaFindings
      parameters:
        - in: query
          name: entity
          required: false
          schema:
            type: string
            format: uuid
        - in: query
          name: severity
          required: false
          schema:
            type: string
            enum: [low, medium, high, critical]
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Findings sorted newest-first
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdverseMediaFindingsResponse"

  # ── UBO Graph (Lane 2D) ───────────────────────────────────────────────
  /api/v1/ubo/screen:
    post:
      tags: [UBO Graph]
      summary: Build (or fetch cached) beneficial-owner graph for an entity
      operationId: screenUboGraph
      description: |
        Returns the cached graph immediately on hit (within 90-day TTL); on
        cache miss inserts a queued row and returns `202` with the
        `graph_id`. The Python worker drains queued rows asynchronously.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UboScreenRequest"
      responses:
        "200":
          description: Cached graph
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UboGraphCached"
        "202":
          description: Graph build queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UboGraphQueued"

  /api/v1/ubo/graph/{id}:
    get:
      tags: [UBO Graph]
      summary: Fetch a UBO graph by id
      operationId: getUboGraph
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Graph (status=queued|ready)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UboGraphResponse"

  # ── LC Validation (Lane 2E) ───────────────────────────────────────────
  /api/v1/lc/validate:
    post:
      tags: [LC Validation]
      summary: Run UCP 600 rule engine against an LC + invoice + Bill of Lading
      operationId: validateLetterOfCredit
      description: |
        Pass structured extracted fields (the bank's OCR / MT700 parser is
        the source). Returns a discrepancy report with aggregated severity
        and a `honor | inquiry | refuse` recommendation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LcValidateRequest"
      responses:
        "200":
          description: Discrepancy report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LcValidateResponse"

  /api/v1/lc/reports/{id}:
    get:
      tags: [LC Validation]
      summary: Fetch a past LC validation run
      operationId: getLcValidationRun
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Validation run
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LcValidationRunResponse"

  # ── Audit Export ──────────────────────────────────────────────────────
  /api/v1/audit:
    get:
      tags: [Audit]
      summary: Export your append-only API audit trail
      operationId: exportAuditLog
      description: |
        Returns the caller's own `api_request_log` rows for the requested
        window. JSON or CSV. Banks use this as compliance evidence during
        their own regulatory audits.
      parameters:
        - in: query
          name: days
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 90
        - in: query
          name: format
          required: false
          schema:
            type: string
            enum: [json, csv]
            default: json
      responses:
        "200":
          description: Audit rows
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditExportResponse"
            text/csv:
              schema:
                type: string
                example: |
                  request_id,route,method,response_status,duration_ms,client_ip
                  abc123,/api/v1/regulatory/check,GET,200,42,203.0.113.5

  # ── Sandbox (Lane 3A-2) ───────────────────────────────────────────────
  /api/v1/keys/sandbox:
    post:
      tags: [Sandbox]
      summary: Issue a free 7-day evaluation API key
      operationId: issueSandboxKey
      description: |
        Returns a sandbox API key valid for 7 days, capped at 100 calls/day,
        scoped to read-only public endpoints (`/api/v1/regulatory/*` and
        `/api/v1/clusters/check`). No signup required, but email is captured
        for upgrade outreach. Rate limited to 3 keys per IP per hour and
        5 per email per day. Anonymous (no member binding) — promote to a
        production key at `/pricing`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SandboxKeyRequest"
            examples:
              minimal:
                summary: Minimal request
                value:
                  email: "compliance@example.bank"
              full:
                summary: With attribution data
                value:
                  email: "compliance@example.bank"
                  company: "Example Bank Trade Finance"
                  use_case: "Evaluating /regulatory/check for Saudi Arabia crude exports"
      responses:
        "201":
          description: Sandbox key issued — store the `key` value, it is not shown again
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SandboxKeyResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          description: Sandbox quota exhausted for this IP or email

  # ── Production keys (CRUD) ────────────────────────────────────────────
  /api/v1/keys:
    get:
      tags: [Keys]
      summary: List your production API keys
      operationId: listKeys
      description: |
        Returns the caller's production API keys with metadata
        (last_used_at, scopes, environment, expires_at). Secret values
        are never returned after issuance.
      responses:
        "200":
          description: Keys for the authenticated member
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        key_prefix: { type: string, example: "oilflow_live_abc" }
                        environment: { type: string, enum: [sandbox, production] }
                        scopes:
                          type: array
                          items: { type: string }
                        is_active: { type: boolean }
                        last_used_at: { type: string, format: date-time, nullable: true }
                        created_at: { type: string, format: date-time }
                        expires_at: { type: string, format: date-time, nullable: true }
    post:
      tags: [Keys]
      summary: Create a production API key
      operationId: createKey
      description: |
        Issues a new production API key. The raw key is shown once on
        creation — store it immediately. Scopes default to the member's
        plan entitlements; pass `scopes` to narrow further.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Human-readable label.
                scopes:
                  type: array
                  items: { type: string }
                  description: |
                    Subset of: regulatory, kyc, cluster, webhooks, reports,
                    adverse_media, ubo, lc, watchlists, audit. Defaults to all
                    scopes available on the member's plan.
                expires_at:
                  type: string
                  format: date-time
                  description: Optional expiry; defaults to never.
      responses:
        "201":
          description: Key created — `key` is shown once
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string, description: "Raw API key — store securely" }
                  key_id: { type: string, format: uuid }
                  scopes:
                    type: array
                    items: { type: string }
                  expires_at: { type: string, format: date-time, nullable: true }
    delete:
      tags: [Keys]
      summary: Revoke a production API key
      operationId: revokeKey
      description: Soft-deletes the key. In-flight requests using it begin failing immediately.
      parameters:
        - in: query
          name: id
          schema: { type: string, format: uuid }
          required: true
          description: Key id to revoke.
      responses:
        "200":
          description: Key revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: boolean }

  # ── Watchlists per-id (Lane 3C-2 detail surface) ──────────────────────
  /api/v1/watchlists/{id}:
    parameters:
      - in: path
        name: id
        schema: { type: string, format: uuid }
        required: true
    get:
      tags: [Watchlists]
      summary: Fetch a watchlist with sample entries and recent matches
      operationId: getWatchlist
      description: |
        Returns the watchlist metadata, the first 25 entries (for
        verification), and the 50 most recent match-log entries.
      responses:
        "200":
          description: Watchlist detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  watchlist:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      description: { type: string, nullable: true }
                      is_active: { type: boolean }
                      created_at: { type: string, format: date-time }
                      last_scanned_at: { type: string, format: date-time, nullable: true }
                  sample_entries:
                    type: array
                    items:
                      type: object
                      properties:
                        entity_name: { type: string }
                        aliases:
                          type: array
                          items: { type: string }
                        jurisdictions:
                          type: array
                          items: { type: string }
                  recent_matches:
                    type: array
                    items:
                      type: object
                      properties:
                        matched_at: { type: string, format: date-time }
                        source:
                          type: string
                          enum: [adverse_media, blocklist_upgrade, sanctions_delta]
                        detail:
                          type: object
                          additionalProperties: true
                  total_matches: { type: integer }
        "404":
          description: Watchlist not found or not owned by the caller
    delete:
      tags: [Watchlists]
      summary: Delete a watchlist
      operationId: deleteWatchlist
      description: Soft-deletes the watchlist. The daily sync agent skips it on next run.
      responses:
        "200":
          description: Watchlist deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        "404":
          description: Watchlist not found

  # ── Pre-Deal Copilot (SKU #5) ─────────────────────────────────────────
  /api/v1/predeal/check:
    post:
      tags: [Pre-Deal Copilot]
      summary: Sub-30s pre-deal clearance verdict + restructure suggestion
      operationId: predealCheck
      description: |
        The front-office surface. Paste a proposed deal description and
        get back a clearance probability + verdict tier (clear/review/
        heavy_friction/block), the blockers that drove the verdict, and a
        Claude-drafted restructure suggestion in front-office lexicon.

        Five primitives run in parallel under the hood: cluster blocklist
        lookup (exact + alias), regulatory tradability (origin + destination),
        adverse media (90-day window), sanctions entity name-match,
        verified-counterparty profile lookup. Plus the intelligence layer:
        time-to-clearance, bank-of-record suggestion, regulator-proximity.

        Every call writes a row to the `predeal_checks` decision ledger.
        Set `X-OilFlow-Data-Class: private` to keep the row out of
        cross-customer aggregations (Bank Enterprise default).
      parameters:
        - name: X-OilFlow-Data-Class
          in: header
          required: false
          schema:
            type: string
            enum: [aggregable, private]
          description: |
            Override the row's data classification. `aggregable` (default
            for Solo + Desk tiers) feeds cross-customer corpus
            aggregations (SKU #7 efficacy + counterparty pulse +
            precedent mining). `private` (default for Bank Enterprise)
            keeps the row visible only to the originating member.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [counterparty_name, counterparty_role, product, origin_country]
              properties:
                counterparty_name: { type: string }
                counterparty_role:
                  type: string
                  enum: [buyer, seller]
                product: { type: string, example: "EN590 10ppm" }
                origin_country: { type: string }
                destination_country: { type: string }
                payment_structure: { type: string }
                volume_mt: { type: number }
                notes: { type: string }
      responses:
        "200":
          description: Verdict
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      request_id: { type: string, pattern: '^pdc_' }
                      clearance_probability: { type: integer, minimum: 0, maximum: 100 }
                      verdict_tier:
                        type: string
                        enum: [clear, review, heavy_friction, block]
                      blockers:
                        type: array
                        items:
                          type: object
                          properties:
                            code: { type: string }
                            severity: { type: string }
                            description: { type: string }
                            evidence_link: { type: string }
                      restructure_suggestion: { type: string, nullable: true }
                      estimated_reclear_pct: { type: integer, nullable: true }
                      checks: { type: object }
                      verified_profile: { type: object, nullable: true }
                      verified_lift_applied: { type: integer }
                      intelligence: { type: object }
                      handoff: { type: object }
                      latency_ms: { type: integer }
                      version: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/predeal/pulse/{entity}:
    get:
      tags: [Pre-Deal Copilot]
      summary: 60-second counterparty pulse (cluster + adverse media + sanctions + history)
      operationId: predealPulse
      parameters:
        - name: entity
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Aggregated counterparty pulse brief
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/predeal/precedents:
    get:
      tags: [Pre-Deal Copilot]
      summary: Mine the decision ledger corpus for analogous deals
      operationId: predealPrecedents
      parameters:
        - name: product
          in: query
          schema: { type: string }
        - name: origin
          in: query
          schema: { type: string }
        - name: destination
          in: query
          schema: { type: string }
        - name: role
          in: query
          schema: { type: string, enum: [buyer, seller] }
        - name: days
          in: query
          schema: { type: integer, default: 90 }
      responses:
        "200":
          description: Precedent matches
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/predeal/restructure:
    post:
      tags: [Pre-Deal Copilot]
      summary: Generate three Claude-ranked restructure candidates for a flagged deal
      operationId: predealRestructure
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                request_id: { type: string, pattern: '^pdc_' }
                deal_payload: { type: object }
      responses:
        "200":
          description: Three ranked restructure candidates
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/predeal/negotiate:
    post:
      tags: [Pre-Deal Copilot]
      summary: Draft counter-language in 5 languages, 2 tones
      operationId: predealNegotiate
      responses:
        "200":
          description: Counter-language drafts
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/predeal/history:
    get:
      tags: [Pre-Deal Copilot]
      summary: RM personal decision history + calibration stats
      operationId: predealHistory
      responses:
        "200":
          description: Personal-history view (scoped to caller's api_key_id / member_id)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── Defense Ledger (SKU #6 scaffold) ──────────────────────────────────
  /api/v1/defense/generate:
    post:
      tags: [Defense Ledger]
      summary: Generate a regulator-grade defense pack from a Pre-Deal verdict or KYC screen
      operationId: defenseGenerate
      description: |
        Aggregates evidence_reviewed + blockers_at_decision +
        alternatives_considered + peer_context (last 180 days, same
        counterparty, aggregable only) + regulatory_context, then calls
        Claude Opus for a 200-400 word narrative. Stored in `defense_packs`
        with `signoff_status='draft'`. Provide either `predeal_check_id`
        (live) or `kyc_request_id` (live as of 2026-06-08).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                predeal_check_id: { type: string, format: uuid }
                kyc_request_id: { type: string }
      responses:
        "200":
          description: Defense pack draft created
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/defense/{request_id}/sign:
    post:
      tags: [Defense Ledger]
      summary: MLRO sign-off with SHA-256 cryptographic binding
      operationId: defenseSign
      description: |
        Binds the defense pack to a regulated individual (MLRO / Compliance
        Officer) via attestation. Computes a SHA-256 hash over the
        canonical-JSON serialization of evidence_pack + narrative +
        decision + decision_basis_summary + counterparty_normalized +
        signoff_by + signoff_at + signoff_attestation. Hash stored on
        the row alongside the algo + ordered input list. Subsequent
        modification of any hashed field breaks the binding and is
        detectable via the /verify endpoint. The canonicalization
        algorithm is published at
        /products/defense-ledger/verification.

        Rescind: POST the same endpoint with `attestation: "RESCIND - ..."`.
        Original signoff_hash + signoff_by + signoff_at are preserved.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, pattern: '^df_' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signoff_by, attestation]
              properties:
                signoff_by:
                  type: string
                  minLength: 5
                  description: "MLRO name + bank affiliation"
                  example: "Sarah O'Connor, MLRO, Mashreq Bank Trade Finance"
                attestation:
                  type: string
                  minLength: 20
      responses:
        "200":
          description: Pack signed (or rescinded)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Already signed / already rescinded / rescind requires prior signing

  /api/v1/defense/{request_id}/verify:
    get:
      tags: [Defense Ledger]
      summary: Recompute the canonical hash from the live row + compare to stored signoff_hash
      operationId: defenseVerify
      description: |
        Independent tamper-evidence check. Returns `hash_match: true` when
        the recomputed digest equals the stored signoff_hash. Returns
        `hash_match: false` when evidence_pack / narrative / decision /
        signoff_* fields have been modified after the original sign. For
        zero-trust verification, regulators can recompute the SHA-256
        from raw row data using the published canonicalization algorithm
        without trusting this endpoint's response.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, pattern: '^df_' }
      responses:
        "200":
          description: Match / divergence verdict
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      request_id: { type: string }
                      signoff_status: { type: string }
                      hash_match: { type: boolean }
                      stored_hash: { type: string }
                      computed_hash: { type: string }
                      hash_algo: { type: string }
                      hash_inputs:
                        type: array
                        items: { type: string }
                      signed_by: { type: string }
                      signed_at: { type: string, format: date-time }
                      verification_note: { type: string }
                      examiner_note: { type: string }
                      version: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Pack is in draft (no hash to verify yet)

  /api/v1/defense/{request_id}/raw:
    get:
      tags: [Defense Ledger]
      summary: Return the canonical hashable payload + stored hash for zero-trust verification
      operationId: defenseRaw
      description: |
        Closes the last trust-on-OilFlow gap in the Defense Pack
        verification story. The /verify endpoint computes match/mismatch
        but requires trusting OilFlow's implementation. This endpoint
        returns the EXACT canonical-JSON bytes that fed the SHA-256
        digest at sign time, plus the stored hash + the ordered field
        list. An independent verifier (browser-runnable at
        /products/defense-ledger/verifier) computes SHA-256 in any
        standard library and compares. No OilFlow code runs in the
        verification path.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, pattern: '^df_' }
      responses:
        "200":
          description: Canonical payload + stored hash + verifier instructions
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      request_id: { type: string }
                      signoff_status: { type: string }
                      signoff_hash: { type: string }
                      signoff_hash_algo: { type: string }
                      signoff_hash_inputs:
                        type: array
                        items: { type: string }
                      canonical_payload_bytes:
                        type: string
                        description: The exact UTF-8 string the server SHA-256'd at sign time.
                      canonical_payload_byte_count: { type: integer }
                      payload_fields:
                        type: object
                        description: Unordered object form of the same payload, for independent canonicalization cross-check.
                      verifier_instructions: { type: string }
                      version: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Pack is draft (no signoff_hash to verify yet)

  /api/v1/defense/{request_id}/render:
    get:
      tags: [Defense Ledger]
      summary: Render the evidence pack in a regulator-specific narrative format
      operationId: defenseRender
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, pattern: '^df_' }
        - name: format
          in: query
          required: true
          schema:
            type: string
            enum: [fincen_sar, fca_sysc18, fatf_rec10, mas_notice626]
      responses:
        "200":
          description: Rendered narrative
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/defense/{request_id}/render-print:
    get:
      tags: [Defense Ledger]
      summary: Print-friendly HTML render of a defense pack (Save-as-PDF in browser)
      operationId: defenseRenderPrint
      description: |
        Returns the same regulator-format narrative the JSON `/render`
        endpoint produces, wrapped in a print-styled HTML document. The
        MLRO opens it in a browser, presses Cmd+P / Ctrl+P, saves as PDF.
        Includes the SHA-256 hash binding + verify URL in the footer so an
        examiner can re-verify tamper-evidence on the resulting PDF.
        Rate limit: 30 req/min.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, pattern: '^df_' }
        - name: format
          in: query
          required: true
          schema:
            type: string
            enum: [fincen_sar, fca_sysc18, fatf_rec10, mas_notice626]
      responses:
        "200":
          description: HTML document (text/html)
          content:
            text/html:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Verified Counterparty Network ─────────────────────────────────────
  /api/v1/verified/submit:
    post:
      tags: [Verified Counterparty Network]
      summary: Counterparty self-serve submission (public, IP-rate-limited)
      operationId: verifiedSubmit
      description: |
        Counterparty submits company info + 4 attestations + optional
        supporting documents (UBO disclosure, company registration,
        reference letter, license, trade-history sample) + optional
        reference contacts. Lands as `status='pending'` for operator
        moderation. Cross-checked against `broker_scam_blocklist` —
        confirmed-fraud matches reject outright; likely/suspected matches
        surface as soft-match flags for priority operator review.
        Public endpoint, no auth, 5 req/min per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company_name, jurisdiction, submitter_email, submitter_role, attestation]
              properties:
                company_name: { type: string, minLength: 2 }
                jurisdiction: { type: string }
                submitter_email: { type: string, format: email }
                submitter_role: { type: string }
                company_registration_id: { type: string }
                attestation:
                  type: object
                  required: [ubo_disclosed, no_sanctions_exposure, trade_history_verifiable, accept_review_terms]
                  properties:
                    ubo_disclosed: { type: boolean, enum: [true] }
                    no_sanctions_exposure: { type: boolean, enum: [true] }
                    trade_history_verifiable: { type: boolean, enum: [true] }
                    accept_review_terms: { type: boolean, enum: [true] }
                documents:
                  type: array
                  maxItems: 20
                  items:
                    type: object
                    required: [document_type, url]
                    properties:
                      document_type:
                        type: string
                        enum: [ubo_disclosure, company_registration, reference_letter, trade_history_sample, license, audited_financials, other]
                      url: { type: string, format: uri }
                      description: { type: string }
                      issuer: { type: string }
                reference_contacts:
                  type: array
                  maxItems: 10
                  items:
                    type: object
                    required: [name, company, email, relationship]
                    properties:
                      name: { type: string }
                      company: { type: string }
                      email: { type: string, format: email }
                      relationship: { type: string }
      responses:
        "200":
          description: Submission accepted into moderation queue
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: Already-submitted slug exists OR confirmed-fraud cluster match blocks submission

  /api/v1/verified/{slug}/update-documents:
    post:
      tags: [Verified Counterparty Network]
      summary: Counterparty self-service document + reference-contact refresh on an approved profile
      operationId: verifiedUpdateDocuments
      description: |
        Approved counterparties incrementally refresh documents +
        reference contacts (e.g. UBO changes, license renewal, new
        reference contacts after additional deals close). Documents +
        contacts are APPENDED, never replaced. IP-rate-limited 3 req/hour.
        Fires `verified_profile.documents_updated` webhook on success.

        Auth — two paths accepted:
        Preferred (magic-link, migration 168): obtain a session token via
        the request-update → update-token flow, then send
        `Authorization: Bearer <session_token>`. Hardened against email
        spoofing; the email body field is ignored when a Bearer token is
        present. Interim (migration 167 fallback): include
        `submitter_email` in the body. Case-insensitive match against
        the email on file authorizes the update. Kept for backwards
        compatibility while customers migrate to magic-link.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
        - name: Authorization
          in: header
          required: false
          schema:
            type: string
            example: "Bearer <session_token>"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submitter_email:
                  type: string
                  format: email
                  description: Required when not using Bearer session-token auth.
                documents:
                  type: array
                  items: { type: object }
                reference_contacts:
                  type: array
                  items: { type: object }
      responses:
        "200":
          description: Documents appended
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Session token invalid / expired / consumed
        "403":
          description: submitter_email does not match the email on file (interim auth path)
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Profile not in 'approved' status, wrong profile_type, or total-cap exceeded

  /api/v1/verified/{slug}/request-update:
    post:
      tags: [Verified Counterparty Network]
      summary: Step 1 of the magic-link self-update flow — emails a 30-min single-use token
      operationId: verifiedRequestUpdate
      description: |
        Issues a short-lived single-use magic-link token and emails it
        to the email on file for the profile (we deliberately do NOT
        read any email from the request body — defeats the email-spoof
        vector). The link expires in 30 minutes and the response never
        reveals which email was contacted. Rate limit: 3 req/hour per
        IP + 5 req/24h per slug.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Magic-link email queued
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Profile not approved / wrong profile_type / no submitter_email on file / per-slug daily cap reached

  /api/v1/verified/{slug}/update-token/{token}:
    get:
      tags: [Verified Counterparty Network]
      summary: Step 2 of the magic-link self-update flow — exchanges magic-link for 24-hour session token
      operationId: verifiedExchangeUpdateToken
      description: |
        Validates the magic-link token (kind=magic_link, status=pending,
        not expired, slug match, email on file still matches the email
        the token was issued to) and issues a 24-hour session token.
        Magic-link row flips to status='consumed' atomically.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
        - name: token
          in: path
          required: true
          schema: { type: string, minLength: 16 }
      responses:
        "200":
          description: Session token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      slug: { type: string }
                      session_token: { type: string }
                      expires_at: { type: string, format: date-time }
                      expires_in_seconds: { type: integer }
                      use_with: { type: string }
                      version: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: Token slug mismatch OR email on file changed since the link was issued
        "404":
          description: Magic-link token not found
        "409":
          description: Token already consumed / profile no longer approved
        "410":
          description: Magic-link token expired

  # ── Public anyone-can-check surfaces ──────────────────────────────────
  /api/public/clusters/submit:
    post:
      tags: [Scam Cluster Intelligence Feed]
      summary: Public anon mirror of /api/v1/clusters/submit (no auth)
      operationId: publicClusterSubmit
      description: |
        Same writer + same TOS licensing as the auth-gated endpoint, but
        gated by IP rate-limit (5 req/min) + per-submitter daily cap
        (50/24h) + honeypot. For journalists, victims, compliance officers
        who don't have an OilFlow API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [submitter_email, entity_name, pitch_summary]
              properties:
                submitter_email: { type: string, format: email }
                entity_name: { type: string, minLength: 2, maxLength: 300 }
                pitch_summary: { type: string, minLength: 40, maxLength: 5000 }
                submitter_role: { type: string }
                submitter_affiliation: { type: string }
                entity_country: { type: string }
                entity_linkedin_url: { type: string, format: uri }
                contact_channels:
                  type: array
                  items: { type: string }
                evidence_urls:
                  type: array
                  items: { type: string, format: uri }
                evidence_notes: { type: object }
                website:
                  type: string
                  description: Honeypot. Leave empty — bots fill it; humans don't see it.
      responses:
        "200":
          description: Submission accepted
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          description: Submitter daily cap or IP rate limit

  /api/public/verified/lookup:
    get:
      tags: [Verified Counterparty Network]
      summary: Public anyone-can-check lookup against verified directory + cluster blocklist
      operationId: publicVerifiedLookup
      description: |
        Friction-free D&B-style lookup. Returns a tier (block / caution /
        verified / unknown) + the matching verified profile (if any) +
        the matching cluster blocklist row (if any) + a recommendation
        narrative. No auth required, 15 req/min per IP.
      parameters:
        - name: entity
          in: query
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 300
      responses:
        "200":
          description: Lookup result with recommendation
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      query: { type: string }
                      entity_normalized: { type: string }
                      verified: { type: object, nullable: true }
                      cluster_match: { type: object, nullable: true }
                      recommendation: { type: string }
                      recommendation_tier:
                        type: string
                        enum: [block, caution, verified, unknown]
                      next_steps: { type: object }
                      version: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"

  # ── Backfilled 2026-06-08 during production-readiness audit ─────────
  # The routes below existed and were customer-facing but were missing
  # from the OpenAPI spec. Drift detector (scripts/audit_drift.py) now
  # passes 5/5 categories clean. Concise schemas — full request/response
  # shapes documented in each route file's leading JSDoc.

  /api/v1/intel:
    get:
      tags: [Compliance Intelligence]
      summary: Latest market intelligence aggregations (signals + benchmarks + morning brief)
      operationId: getIntel
      description: |
        Returns the most recent OilFlow market-intel snapshot. Aggregates
        regulatory-matrix deltas, sanctions-list updates, cluster-blocklist
        adds, and the daily morning brief.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 30, default: 1 }
      responses:
        "200":
          description: Intel snapshot
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/signals:
    get:
      tags: [Compliance Intelligence]
      summary: Latest market signals (price dislocations, tender alerts, supply disruptions)
      operationId: getSignals
      description: |
        Real-time signal feed surfaced from the news_signal agent + adverse-
        media monitor. Rate-limited 60/min per API key.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Signals list
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/search:
    post:
      tags: [Compliance Intelligence]
      summary: Universal compliance query — sanctions + PEP + clusters + adverse media + corporate registry + UBO + trade refs
      operationId: globalSearch
      description: |
        Unified compliance query interface. One POST returns aggregated
        signals from every OilFlow surface: sanctions (OFAC + UN + EU +
        OFSI + 9 more), PEP, OilFlow cluster blocklist, multilingual
        adverse media, OpenCorporates + GLEIF, regulatory ruleset, UBO
        graph, vessel/cargo links, trade-reference history, weighted
        aggregate risk score (0-100). The "Bloomberg of compliance"
        entry point for any new query.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Counterparty name or normalized entity string" }
                country: { type: string, description: "Optional ISO 3166-1 alpha-2 hint" }
      responses:
        "200":
          description: Aggregated query result with weighted risk score
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/kyc/results/{request_id}:
    get:
      tags: [KYC-as-API]
      summary: Poll an async KYC pipeline job result
      operationId: kycResults
      description: |
        Customer poll target after the synchronous /api/v1/kyc/screen
        response. Returns `status='queued'|'running'` until the agent
        finishes the 5 async steps, then `status='completed'` with the
        full merged results.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Async job status + (when complete) full results
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: API key memberId does not match the job's member_id
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/kyc/trade-refs:
    post:
      tags: [KYC-as-API]
      summary: Submit trade-reference contacts for a KYC counterparty
      operationId: submitTradeRefs
      description: |
        Customer compliance officer submits up to 3 trade-reference
        contacts for a counterparty under KYC. OilFlow emails each
        reference with a unique tokenized response link; responses flow
        back into the kyc_async_jobs result.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [counterparty_name, references]
              properties:
                kyc_request_id: { type: string, nullable: true }
                counterparty_name: { type: string }
                references:
                  type: array
                  maxItems: 3
                  items:
                    type: object
                    required: [name, email, relationship]
                    properties:
                      name: { type: string }
                      email: { type: string, format: email }
                      org: { type: string }
                      relationship:
                        type: string
                        enum: [supplier, buyer, service_provider, other]
      responses:
        "200":
          description: References accepted, emails dispatched
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/registry/lookup:
    get:
      tags: [KYC-as-API]
      summary: Corporate registry lookup (OpenCorporates + GLEIF cross-reference)
      operationId: registryLookup
      description: |
        Wraps OpenCorporates as the primary global registry and cross-
        references GLEIF for an authoritative LEI when one exists.
        Returns the single canonical company record across both sources.
        For jurisdictions outside OpenCorporates' 140+ index, response
        includes `coverage: "skeletal"` and falls back to the GLEIF
        result only — honest framing, no fake hits.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: country
          in: query
          required: true
          schema: { type: string, description: "ISO 3166-1 alpha-2" }
        - name: name
          in: query
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 25, default: 5 }
      responses:
        "200":
          description: Resolved company record + GLEIF LEI (or skeletal-coverage marker)
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/profile/verified:
    get:
      tags: [Verified Counterparty Network]
      summary: Read the caller's verified-by-OilFlow profile (opt-in badge)
      operationId: getVerifiedProfile
      description: |
        Returns the authenticated member's verified_profiles row, or null
        if not opted in. Lane 3B-4 opt-in toggle for customers (distinct
        from the counterparty-self-serve flow at /api/v1/verified/submit).
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Verified profile row or null
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Verified Counterparty Network]
      summary: Opt in — publish a verified-by-OilFlow profile
      operationId: postVerifiedProfile
      description: |
        Upserts a verified_profiles row keyed by member_id with a slug
        derived from company_name + member id. Only members with
        verification_status='verified' may opt in.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Profile published
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Member not verified — opt-in blocked
    delete:
      tags: [Verified Counterparty Network]
      summary: Opt out — un-publish the verified-by-OilFlow profile
      operationId: deleteVerifiedProfile
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Profile retracted; /verified/{slug} returns 404 on next request
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/watchlists:
    get:
      tags: [Customer Watchlists]
      summary: List the customer's watchlists with entry counts + last-scanned time
      operationId: listWatchlists
      description: |
        Lane 3C-2 customer watchlist management. Daily agents/watchlist_sync
        scans every active entry against fresh adverse_media_findings,
        cluster blocklist upgrades, and sanctions_screening_log hits —
        firing `watchlist.match_detected` via the Lane 2A webhook pipeline
        on each match.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Watchlist list
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Customer Watchlists]
      summary: Create a watchlist from a CSV body
      operationId: createWatchlist
      description: |
        CSV format: `entity_name[,aliases][,jurisdictions]`. Aliases and
        jurisdictions are pipe-separated lists inside the CSV cell. The
        first column (entity_name) is required.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
      responses:
        "201":
          description: Watchlist created
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/matches:
    get:
      tags: [Compliance Intelligence]
      summary: List the caller's recent matches (legacy marketplace surface, retained for API customers using the deal-attribution lookup)
      operationId: listMatches
      description: |
        Pre-pivot marketplace surface retained for backwards compatibility
        with API customers that integrated against it. New integrations
        should prefer /api/v1/kyc/screen + /api/v1/predeal/check instead.
        Will be sunset 2027-Q1 with at least 90 days notice.
      security: [{ bearerAuth: [] }]
      deprecated: true
      responses:
        "200":
          description: Matches list
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/verified/{slug}/request-update:
    post:
      tags: [Verified Counterparty Network]
      summary: Step 1 of the magic-link self-update flow — issue a short-lived token to the email on file
      operationId: verifiedRequestUpdate
      description: |
        Issues a 30-minute single-use magic-link token, hashed (SHA-256)
        in the DB, and emails the bare token to verified_profiles.submitter_email.
        Defeats the email-spoof attack: we ignore any email in the request
        body and always email the address on file.

        Rate limits: 3 req/hour per IP + 5 req/24h per slug. Public, no auth.
        Backed by migration 168 (verified_profile_update_tokens).
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: Magic link dispatched (no body — does not reveal whether the slug exists)
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/verified/{slug}/update-token/{token}:
    get:
      tags: [Verified Counterparty Network]
      summary: Step 2 of the magic-link self-update flow — exchange magic-link token for a 24h session
      operationId: verifiedExchangeToken
      description: |
        Validates the magic-link token (status='pending', not expired,
        issued for this slug, submitter_email still matches the email
        the token was issued to). On success: flips magic-link row to
        consumed, inserts session row with parent_token_id reference,
        returns the bare session token in the response body. Client
        stores in localStorage and uses as Bearer auth on /update-documents.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Session token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_token: { type: string }
                  expires_at: { type: string, format: date-time }
        "401":
          description: Magic-link token invalid / expired / already consumed
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Production keys begin with `oilflow_`. Pass as `Authorization: Bearer <key>`.
        Request beta access at api@oilflow.us.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Alternative to bearer; same key value.
  responses:
    BadRequest:
      description: Invalid request parameters or body
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Missing, invalid, or quota-exhausted API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: Per-IP+key rate limit exceeded (per-minute window)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    PaymentRequired:
      description: |
        Subscription or quota gate. The `error.code` distinguishes the cause:
        `no_subscription` (key has no member/subscription), `wrong_tier`
        (current tier does not include this product), `subscription_inactive`
        (billing problem — resolve at /api/stripe/portal), `quota_exhausted`
        (monthly cap used; resets on the 1st UTC — upgrade for more).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            ok: false
            error:
              code: "quota_exhausted"
              message: "Monthly Pre-Deal verdict quota used (50/50). Resets on the 1st (UTC). Upgrade to Desk (1,000/mo) at /pricing or Bank Enterprise (unlimited) via /apply."
  schemas:
    SuccessEnvelope:
      type: object
      required: [ok, data]
      properties:
        ok:
          type: boolean
          const: true
        data:
          type: object
    ErrorResponse:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: "invalid_key"
            message:
              type: string
              example: "API key not recognized or daily quota exceeded."
    CountryRules:
      type: object
      additionalProperties:
        $ref: "#/components/schemas/ProductRule"
    ProductRule:
      type: object
      properties:
        status:
          type: string
          enum: [allowed, restricted, blocked]
        notes:
          type: string
    Country:
      type: object
      properties:
        slug:
          type: string
          example: "pakistan"
        country:
          type: string
          example: "Pakistan"
        rules:
          $ref: "#/components/schemas/CountryRules"
    CountriesListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                count:
                  type: integer
                  example: 79
                version:
                  type: string
                  example: "v1-beta"
                countries:
                  type: array
                  items:
                    $ref: "#/components/schemas/Country"
    SingleCountryResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                country:
                  $ref: "#/components/schemas/Country"
                version:
                  type: string
                  example: "v1-beta"
    RegulatoryCheckResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                country:
                  type: string
                product:
                  type: string
                listing_type:
                  type: string
                  enum: [supply, demand]
                allowed:
                  type: boolean
                blockers:
                  type: array
                  items:
                    type: object
                    properties:
                      country:
                        type: string
                      product:
                        type: string
                      reason:
                        type: string
                note:
                  type: string
                  description: Present only when country is not in the matrix (defaults to allowed)
    ProductsListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                count:
                  type: integer
                version:
                  type: string
                products:
                  type: array
                  items:
                    type: object
                    properties:
                      category:
                        type: string
                      label:
                        type: string
    KycScreenRequest:
      type: object
      required: [company_name]
      properties:
        company_name:
          type: string
          minLength: 2
          description: >-
            Counterparty company name. Must contain ≥2 alphanumeric
            characters after normalization (strip non-alphanumeric, case-fold).
            Unicode-only inputs that normalize to empty are rejected.
          example: "Acme Trading FZE"
        country:
          type: string
          example: "United Arab Emirates"
        product:
          type: string
          example: "crude oil"
        listing_type:
          type: string
          enum: [supply, demand]
          default: demand
        directors:
          type: array
          items:
            type: string
            minLength: 1
          description: >-
            Director names to also screen against the cluster blocklist.
            Must be an array of non-empty strings — non-string elements
            return 400 invalid_field with the offending index.
          example: ["Jane Doe", "John Smith"]
        metadata:
          type: object
          description: Free-form metadata, echoed back in request audit log
    KycScreenResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                request_id:
                  type: string
                  example: "kyc_l2v9c_abc123"
                company_name:
                  type: string
                verdict:
                  type: string
                  enum: [pass, review, fail]
                verdict_reasoning:
                  type: string
                checks:
                  type: object
                  description: Per-step results; cluster_blocklist + regulatory_tradability run synchronously; remaining 6 KYC steps are returned as queued pending full pipeline GA Q3 2026
                version:
                  type: string
                  example: "v1-beta"
    ClusterEntry:
      type: object
      properties:
        id:
          type: string
          format: uuid
        entity_name:
          type: string
        entity_normalized:
          type: string
        entity_country:
          type: string
          nullable: true
        severity:
          type: string
          enum: [confirmed, likely, suspected]
        reason:
          type: string
        aliases:
          type: array
          items:
            type: string
        registration_number:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        source_document:
          type: string
          nullable: true
    ClusterListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                count:
                  type: integer
                version:
                  type: string
                filters:
                  type: object
                  properties:
                    severity:
                      type: string
                      nullable: true
                    country:
                      type: string
                      nullable: true
                    since:
                      type: string
                      nullable: true
                clusters:
                  type: array
                  items:
                    $ref: "#/components/schemas/ClusterEntry"
    ClusterCheckResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                entity:
                  type: string
                matched:
                  type: boolean
                matches:
                  type: array
                  items:
                    $ref: "#/components/schemas/ClusterEntry"
                highest_severity:
                  type: string
                  enum: [confirmed, likely, suspected]
                  nullable: true

    # ── Lane 2A schemas ────────────────────────────────────────────────
    RescreenRequest:
      type: object
      required: [entity_id]
      properties:
        entity_id:
          type: string
          format: uuid
          description: kyc_dossiers.id of a previously registered entity
    RescreenAccepted:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                screening_run_id:
                  type: string
                  format: uuid
                entity_id:
                  type: string
                  format: uuid
                status:
                  type: string
                  enum: [queued]
                queued_at:
                  type: string
                  format: date-time

    WebhookSubscriptionRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: HTTPS only. Slack / Teams incoming-webhook URLs accepted.
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - kyc.match_detected
              - kyc.rescreen_completed
              - sanctions.list_updated
              - cluster.entity_added
              - cluster.entity_severity_changed
              - regulatory.rule_changed
              - workflow.envelope_signed
              - workflow.audit_event
              - adverse_media.match_detected
              - watchlist.match_detected
        description:
          type: string
          maxLength: 500
        delivery_format:
          type: string
          enum: [raw, slack, teams]
          default: raw
    WebhookSubscriptionCreated:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                subscription:
                  $ref: "#/components/schemas/WebhookSubscription"
                secret:
                  type: string
                  description: HMAC signing secret. Shown once. Rotation requires DELETE + POST.
                note:
                  type: string
    WebhookSubscription:
      type: object
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        events:
          type: array
          items:
            type: string
        description:
          type: string
          nullable: true
        is_active:
          type: boolean
        delivery_format:
          type: string
          enum: [raw, slack, teams]
        created_at:
          type: string
          format: date-time
        last_success_at:
          type: string
          format: date-time
          nullable: true
        last_failure_at:
          type: string
          format: date-time
          nullable: true
        consecutive_failures:
          type: integer
    WebhookSubscriptionsResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                subscriptions:
                  type: array
                  items:
                    $ref: "#/components/schemas/WebhookSubscription"
    WebhookEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        event_id:
          type: string
          format: uuid
        subscription_id:
          type: string
          format: uuid
        event_type:
          type: string
        status:
          type: string
          enum: [pending, delivering, delivered, failed, dlq]
        attempt_count:
          type: integer
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        next_attempt_at:
          type: string
          format: date-time
        response_code:
          type: integer
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    WebhookEventsResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                events:
                  type: array
                  items:
                    $ref: "#/components/schemas/WebhookEvent"

    # ── Lane 2B schemas ────────────────────────────────────────────────
    ReportTemplate:
      type: object
      properties:
        template_id:
          type: string
          enum: [fincen_sar, fatf_rec10, fca_sysc18, mas_notice_626, ofsi_annual, ffiec_bsa]
        regulator:
          type: string
        jurisdiction:
          type: string
        title:
          type: string
        description:
          type: string
    ReportTemplatesResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                templates:
                  type: array
                  items:
                    $ref: "#/components/schemas/ReportTemplate"
    ReportRequest:
      type: object
      required: [entity_id, template_id]
      properties:
        entity_id:
          type: string
          format: uuid
        template_id:
          type: string
          enum: [fincen_sar, fatf_rec10, fca_sysc18, mas_notice_626, ofsi_annual, ffiec_bsa]
    ReportCreated:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                report_id:
                  type: string
                  format: uuid
                template_id:
                  type: string
                status:
                  type: string
                  enum: [ready]
                artifact_format:
                  type: string
                  enum: [html, pdf]
                pdf_storage_path:
                  type: string
                pdf_sha256:
                  type: string
                generated_at:
                  type: string
                  format: date-time
                expires_at:
                  type: string
                  format: date-time
    ReportResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                report:
                  type: object
                  additionalProperties: true
                download_url:
                  type: string
                  nullable: true
                download_url_expires_in_seconds:
                  type: integer
                  nullable: true
    ReportAuditResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                report_id:
                  type: string
                  format: uuid
                entity_id:
                  type: string
                  format: uuid
                template_id:
                  type: string
                generated_at:
                  type: string
                  format: date-time
                pdf_sha256:
                  type: string
                evidence:
                  type: object
                  additionalProperties: true

    # ── Lane 2C schemas ────────────────────────────────────────────────
    MonitoredEntity:
      type: object
      properties:
        id:
          type: string
          format: uuid
        entity_name:
          type: string
        aliases:
          type: array
          items:
            type: string
        languages:
          type: array
          items:
            type: string
            enum: [en, ar, ur, zh, ru, es, fr, sw]
        jurisdictions:
          type: array
          items:
            type: string
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
        notes:
          type: string
          nullable: true
    MonitoredEntitiesResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                entities:
                  type: array
                  items:
                    $ref: "#/components/schemas/MonitoredEntity"
    MonitoredEntityRequest:
      type: object
      required: [entity_name]
      properties:
        entity_name:
          type: string
          maxLength: 300
        aliases:
          type: array
          maxItems: 20
          items:
            type: string
        languages:
          type: array
          items:
            type: string
            enum: [en, ar, ur, zh, ru, es, fr, sw]
        jurisdictions:
          type: array
          maxItems: 20
          items:
            type: string
        notes:
          type: string
          maxLength: 500
    MonitoredEntityCreated:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                entity:
                  $ref: "#/components/schemas/MonitoredEntity"
    AdverseMediaFinding:
      type: object
      properties:
        id:
          type: string
          format: uuid
        monitored_entity_id:
          type: string
          format: uuid
        source:
          type: string
        source_url:
          type: string
        language:
          type: string
        article_title:
          type: string
        article_published_at:
          type: string
          format: date-time
          nullable: true
        match_score:
          type: number
          format: float
        severity:
          type: string
          enum: [low, medium, high, critical]
        topic_tags:
          type: array
          items:
            type: string
        summary:
          type: string
        found_at:
          type: string
          format: date-time
    AdverseMediaFindingsResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                findings:
                  type: array
                  items:
                    $ref: "#/components/schemas/AdverseMediaFinding"

    # ── Lane 2D schemas ────────────────────────────────────────────────
    UboScreenRequest:
      type: object
      required: [root_entity_name, root_jurisdiction]
      properties:
        root_entity_name:
          type: string
        root_jurisdiction:
          type: string
          description: ISO 3166-1 alpha-2 country code (or jurisdiction slug).
        force_refresh:
          type: boolean
          default: false
    UboGraphCached:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                cache_hit:
                  type: boolean
                  example: true
                graph:
                  $ref: "#/components/schemas/UboGraph"
    UboGraphQueued:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                cache_hit:
                  type: boolean
                  example: false
                status:
                  type: string
                  enum: [queued]
                graph_id:
                  type: string
                  format: uuid
                note:
                  type: string
    UboGraph:
      type: object
      properties:
        id:
          type: string
          format: uuid
        root_entity_name:
          type: string
        root_jurisdiction:
          type: string
        graph_json:
          type: object
          properties:
            nodes:
              type: array
              items:
                type: object
                additionalProperties: true
            edges:
              type: array
              items:
                type: object
                additionalProperties: true
        risk_score:
          type: number
        flagged_patterns:
          type: array
          items:
            type: string
            enum:
              - shell_in_shell
              - opaque_jurisdiction_concentration
              - repeating_directors
              - registered_agent_overlap
              - depth_limit_reached_without_natural_person
        traversal_depth:
          type: integer
        natural_persons_reached:
          type: integer
        opaque_jurisdictions_hit:
          type: array
          items:
            type: string
        registries_consulted:
          type: array
          items:
            type: string
        generated_at:
          type: string
          format: date-time
        cached_until:
          type: string
          format: date-time
    UboGraphResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                status:
                  type: string
                  enum: [queued, ready]
                graph:
                  $ref: "#/components/schemas/UboGraph"

    # ── Lane 2E schemas ────────────────────────────────────────────────
    LcValidateRequest:
      type: object
      required: [lc, invoice, bl]
      properties:
        lc:
          type: object
          additionalProperties: true
          description: Extracted LC fields (amount, currency, beneficiary, port_of_loading, expiry_date, etc.).
        invoice:
          type: object
          additionalProperties: true
        bl:
          type: object
          additionalProperties: true
    Discrepancy:
      type: object
      properties:
        rule_id:
          type: string
        ucp600_article:
          type: string
        severity:
          type: string
          enum: [minor, major, critical]
        field:
          type: string
        expected: {}
        observed: {}
        narrative:
          type: string
    LcValidateResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                run_id:
                  type: string
                  format: uuid
                status:
                  type: string
                  enum: [ready]
                discrepancies:
                  type: array
                  items:
                    $ref: "#/components/schemas/Discrepancy"
                severity:
                  type: string
                  enum: [clean, minor, major, critical]
                recommendation:
                  type: string
                  enum: [honor, inquiry, refuse]
                ucp600_articles_cited:
                  type: array
                  items:
                    type: string
    LcValidationRunResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                run:
                  type: object
                  additionalProperties: true

    # ── Audit ───────────────────────────────────────────────────────────
    AuditExportRow:
      type: object
      properties:
        request_id:
          type: string
        route:
          type: string
        method:
          type: string
        response_status:
          type: integer
        duration_ms:
          type: integer
        client_ip:
          type: string
          nullable: true
        request_params:
          type: object
          additionalProperties: true
        response_summary:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
    AuditExportResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                rows:
                  type: array
                  items:
                    $ref: "#/components/schemas/AuditExportRow"
                count:
                  type: integer

    # ── Sandbox ─────────────────────────────────────────────────────────
    SandboxKeyRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          maxLength: 254
          description: Used for upgrade outreach + rate limiting.
        company:
          type: string
          maxLength: 200
        use_case:
          type: string
          maxLength: 500
          description: One-sentence description of what the caller wants to evaluate.
    SandboxKeyResponse:
      type: object
      properties:
        key:
          type: string
          description: Throwaway sandbox key — store it now, it will not be shown again.
        key_id:
          type: string
          format: uuid
        environment:
          type: string
          enum: [sandbox]
        scopes:
          type: array
          items:
            type: string
          example: ["regulatory", "cluster"]
        expires_at:
          type: string
          format: date-time
        daily_call_cap:
          type: integer
          example: 100
        message:
          type: string
