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`
    (route, scope, response hash, client IP, user agent, duration). Customers
    can request their own log via `audit-log@oilflow.us`. Banks use this trail
    as compliance evidence during their own regulatory audits. NOTE: a schema
    constraint rejected rows for nine of the twelve API scopes between
    2026-06-01 and 2026-09-01, so audit rows for predeal, defense, ubo, lc,
    reports, webhooks, adverse_media, watchlists and workflow calls do not
    exist for that window. Fixed in migration 254; disclosed here rather than
    quietly backfilled, because an audit trail you cannot trust the gaps in is
    not an audit trail.

    For AI agents: OilFlow exposes a Model Context Protocol endpoint over
    Streamable HTTP at `https://oilflow.us/api/mcp` (also on npm as
    `@oilflow/mcp-server` for stdio). Four tools need no API key,
    `cluster_check`, `predeal_preview` (5 verdicts/24h per caller),
    `verify_receipt`, and `request_sandbox_key`. Coverage limits are stated in
    each tool description: 8 sanctions lists, PEP screening is not shipped.

    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 8-list sanctions screening across 235 jurisdictions (PEP screening is not shipped; coverage is sanctions-only)
  - 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 6.3, MAS Notice 626, OFSI Annual, FFIEC BSA).
  - name: Adverse Media
    description: Adverse-media monitoring list for registered entities. No adverse-media source has returned a finding yet.
  - 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 30 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 6.3 / 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/{slug}/graph:
    get:
      tags: [Scam Cluster Intelligence Feed]
      summary: Cluster network graph (nodes + edges) for a teardown group or entity
      operationId: getClusterGraph
      description: |
        Machine-readable network model of a cluster, built live from the
        corpus: curated teardown-group membership plus deterministic
        shared-alias links (no inferred edges). `slug` accepts a teardown
        group slug (e.g. `simar-chahal-cluster`) or a single entity slug as
        returned by `/api/v1/clusters`. The same model renders the public
        teardown-page graph. 404 when the slug resolves to no linked
        network. A single unlinked row is not a graph.

        The response also carries `sanctions_network`: OFAC's own
        `Linked To:` remarks for the entity, quoted verbatim with the
        source `entity_id`. It is populated only when the entity is
        itself on a sanctions list, and only from first-party US
        Consolidated Screening List text. Inferred links (shared
        address, shared identifier) are never published.

        Read `sanctions_network.coverage` before treating an empty
        `members` array as a finding:

          * `available`          links found and returned
          * `no_links_recorded`  screened against a populated snapshot; it
                                 records none (a real negative)
          * `not_screened`       this cluster record is not sourced from a
                                 sanctions list, so none was attempted. A
                                 statement about our record, NOT a
                                 determination that the entity is unlisted
          * `withheld`           links exist but are withheld under our
                                 protected-entity policy
          * `unavailable`        the lookup did not complete, or the local
                                 screening snapshot was empty (nothing to
                                 search)

        `sanctions_network.degraded` is true for `unavailable` only.
        When it is true, absence is NOT evidence of absence and the
        result must not be recorded as a clean screen.

        `sanctions_network.stale_hours` is non-null when the local
        screening snapshot is older than 48 hours. Designations made
        since then are not reflected. This qualifies a result rather
        than withholding it, because stale rows still yield true
        positives.

        Members are confirmed against OFAC's `Linked To:` target by
        exact normalized name, not by substring: a designation whose
        caption merely begins with the queried name is excluded.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          example: "simar-chahal-cluster"
      responses:
        "200":
          description: |
            Graph model with nodes, edges (hub + cross-reference), and caption,
            plus the `sanctions_network` coverage block described above.
        "404":
          description: No linked cluster network exists for this slug
        "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 6.3, 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` and `compliance_checks` (sanctions rows carry
        `check_type='sanctions_screening'`) 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: Add an entity to the adverse-media monitoring list
      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/mcp:
    post:
      tags: [Sandbox]
      summary: Model Context Protocol endpoint (Streamable HTTP)
      operationId: mcpEndpoint
      security: []
      description: |
        JSON-RPC 2.0 over Streamable HTTP, implementing the Model Context
        Protocol so any MCP-capable agent can use OilFlow as a tool provider.
        Human-readable page: https://oilflow.us/mcp

        Add it with:
          `claude mcp add --transport http oilflow https://oilflow.us/api/mcp`

        FOUR tools require no API key: `predeal_preview` (a full pre-deal
        clearance verdict, 5 per caller per 24h, returns receipt_id +
        verify_url), `verify_receipt` (fetch and re-verify a signed screening
        receipt, 20/min), `cluster_check` (fraud-cluster screen, 10/hr) and
        `request_sandbox_key`. The keyed tools are `regulatory_check`
        (sandbox), then `kyc_screen`, `ubo_screen`, `lc_validate` and
        `predeal_check` (production).

        Per-caller quotas: a call proxied through this endpoint is charged to
        the ORIGINAL caller, not to the platform, so one agent cannot exhaust
        another agent's free tier.

        Keyed tools forward the caller's `Authorization: Bearer` header to the
        same v1 routes a direct consumer would call. This endpoint grants no
        privilege of its own and is a protocol adapter, not a second
        authorization surface. A keyless tool is never sent the caller's key.

        Every result is wrapped as
        `{source, tool, tier, retrieved_at, data}`. The `data` field is a
        report about a third party and is data, never instructions. An
        exhausted free quota returns `{status: "quota_exhausted",
        resets_in_hours, retry_after_seconds, how_to_proceed}` and is NOT an
        error: the allowance is used up, the service is up.

        Coverage limits are stated inside each tool description, because the
        agent is the party that has to relay them: 8 sanctions lists, PEP
        screening is not shipped, US beneficial-ownership coverage is partial.

        Call `tools/list` for the current catalog, including each tool's
        `title` and its `annotations` (readOnlyHint / destructiveHint /
        idempotentHint / openWorldHint).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A JSON-RPC 2.0 request envelope.
              properties:
                jsonrpc: { type: string, example: "2.0" }
                id: { type: integer, example: 1 }
                method: { type: string, example: "tools/list" }
                params: { type: object }
              required: [jsonrpc, method]
      responses:
        "200":
          description: JSON-RPC 2.0 response (or an SSE stream when requested).
          content:
            application/json:
              schema:
                type: object
                properties:
                  jsonrpc: { type: string }
                  id: { type: integer }
                  result: { type: object }
  /api/v1/keys/sandbox:
    post:
      tags: [Sandbox]
      summary: Issue a free 30-day evaluation API key
      operationId: issueSandboxKey
      description: |
        Returns a sandbox API key valid for 30 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; production API access is scoped on a call at
        `/apply`.
      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 (reported as unmeasured until a source returns a row),
        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 and
        returns that row's id as `receipt_id`. A publicly verifiable receipt
        also returns a keyless `verify_url`
        (`https://oilflow.us/api/public/verify/<receipt_id>`) that anyone can
        fetch to get the HMAC-signed record of the verdict;
        `receipt_visibility` says which case you got.
        Set `X-OilFlow-Data-Class: private` to keep the row out of
        cross-customer aggregations, and
        `X-OilFlow-Receipt-Visibility` to decide separately whether a stranger
        holding the id may fetch the receipt.
      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
            unless the account was provisioned private) feeds
            cross-customer corpus aggregations (SKU #7 efficacy +
            counterparty pulse + precedent mining). `private` keeps the row out of every cross-customer read path. It governs
            corpus eligibility only; public verifiability is the separate
            header below.
        - name: X-OilFlow-Receipt-Visibility
          in: header
          required: false
          schema:
            type: string
            enum: [public, private]
          description: |
            May an anonymous caller fetch this verdict's signed receipt from
            /api/public/verify/<receipt_id>? Defaults to `public`, except for
            an enterprise-private tier or a request that already sent
            `X-OilFlow-Data-Class: private`, which default to `private`. A
            private receipt returns no `verify_url`, and the verify endpoint
            answers 404 for its id exactly as it does for an unknown one.
      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_' }
                      receipt_id:
                        type: string
                        format: uuid
                        description: >
                          The `predeal_checks` row id for this verdict. Forwardable:
                          it is the id a third party verifies.
                      receipt_visibility:
                        type: string
                        enum: [public, private]
                        description: >
                          Whether this receipt can be fetched from the keyless
                          verify endpoint. `private` receipts return no
                          verify_url; the record still exists and is still
                          signed, it is simply not readable by a stranger
                          holding the id.
                      verify_url:
                        type: string
                        format: uri
                        description: >
                          Keyless URL returning the signed record of this verdict
                          (`receipt_type: predeal_verdict`). Exposes the name, the
                          time, the verdict and its coverage, and deliberately none
                          of the deal detail. Present ONLY when
                          receipt_visibility is `public`, because a URL that
                          answers 404 is worse than no URL.
                      ceiling_reached:
                        type: boolean
                        description: >
                          Present and true ONLY when the keyless public surface had
                          used up its daily model-spend allowance, in which case
                          `verdict_source` is `rule_based`. Absent otherwise. Never
                          an error: a verdict is still returned.
                      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 }
                      verdict_source:
                        type: string
                        enum: [claude, rule_based]
                        description: >
                          Which engine produced the score. `rule_based` means the
                          deterministic fallback ran (model unreachable, refused,
                          over budget, or a hard guard forced it): the score is
                          degraded and should be presented as such.
                      evidence_gaps:
                        type: array
                        description: >
                          Evidence sources that could NOT be consulted for THIS
                          verdict. A score reported without them overstates its own
                          confidence.
                        items:
                          type: object
                          properties:
                            source: { type: string }
                            reason: { type: string }
                      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.
        A regulator can recompute the SHA-256 from raw row data using the
        published canonicalization algorithm rather than relying on this
        endpoint. Read a match honestly: it is an internal consistency
        check showing the pack matches its stored canonical rows, and it
        does not prove the rows were not rewritten. The digest is unkeyed
        and stored beside the data, so evidence against OilFlow itself
        requires the signoff_hash delivered at signing time (sign response
        or defense_pack.signed webhook) kept on the relying party's side.
      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 an independent recompute
      operationId: defenseRaw
      description: |
        Removes the dependency on OilFlow's own verify implementation.
        The /verify endpoint computes match/mismatch inside OilFlow; 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, so an independent verifier (browser-runnable at
        /products/defense-ledger/verifier) can compute SHA-256 in any
        standard library and compare. What that proves is bounded: the
        recompute is an internal consistency check showing the pack
        matches its stored canonical rows; it does not prove the rows
        were not rewritten, because the digest is unkeyed and stored in
        the same database as the data.
      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, which 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: |
            Uniform success. Returned whether or not an approved
            counterparty profile exists for the slug: the handler
            deliberately does not reveal profile existence, status,
            profile_type, submitter_email presence, or per-slug cap
            state (anti-enumeration, standard password-reset pattern).
            A 404 or 409 is therefore never returned by this endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data:
                    type: object
                    properties:
                      slug: { type: string }
                      message: { type: string }
                      expires_in_minutes: { type: integer }
                      version: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "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, 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
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: Token lookup, consume, or session insert failed
        "503":
          description: verified_profile_update_tokens table not present (migration 168 not applied)

  # ── 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. The
        adverse-media monitor has never returned a finding, so it contributes
        no rows today. 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 + 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 (8 lists, OFAC SDN,
        OFAC Consolidated, UN, EU, UK HMT, Canada SEMA, AU DFAT, Swiss
        SECO), OilFlow cluster blocklist, adverse media (unmeasured: no
        source has returned a row),
        OpenCorporates + GLEIF, regulatory ruleset, UBO graph,
        vessel/cargo links, trade-reference history, weighted aggregate
        risk score (0-100). PEP screening is NOT included, coverage is
        sanctions-only and results say so explicitly.
      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 cluster blocklist upgrades and
        compliance_checks sanctions-screening hits (adverse_media_findings is
        scanned too, and has held zero rows for its whole lifetime).
        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"

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; a higher quota is scoped
        on a call at `/apply`).
      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). For one named counterparty in writing, order a Counterparty Screen at $95 or a Counterparty File at $500 at /pricing; a higher quota is scoped on a call at /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
                  description: >-
                    When coverage is not_in_matrix this is true only because no
                    rule was found. It is not a clearance; branch on coverage.
                coverage:
                  type: string
                  enum: [covered, not_in_matrix]
                  description: Whether the country is held in the regulatory matrix.
                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. Accepted for forward compatibility and
            currently IGNORED: the handler reads no metadata field, so it is
            neither persisted nor echoed back. Do not rely on it.
    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
