openapi: 3.1.0
info:
  title: Falcon Veritas Intelligence (FVI) API
  version: "4.0.0"
  summary: Enterprise reconciliation platform — public API surface.
  description: |
    All endpoints run over TLS 1.2+, require a Supabase-issued JWT bearer
    (or an OAuth2 client-credentials token from `/api/public/oauth/token`),
    and are scoped by tenant via RLS.

    Conventions (see `docs/89_api_conventions.md`):
    - Errors: RFC 7807 Problem+JSON (`application/problem+json`).
    - Pagination: cursor (`limit`, `cursor`) with a `page` envelope; SCIM uses `startIndex`/`count`.
    - Idempotency: `Idempotency-Key` header on non-idempotent verbs; retries with the same body replay the original response.
    - Standard headers: `X-Request-Id`, `X-RateLimit-*`, `Retry-After`, `Idempotency-Replay`.
  contact:
    name: FVI Security
    url: https://falconveritas.com/security
    email: security@falconveritas.com
servers:
  - url: https://app-fvi.falconveritas.com
    description: Production
  - url: https://fvi.lovable.app
    description: Published Lovable URL
security:
  - bearerAuth: []
tags:
  - { name: Auth }
  - { name: Tenants }
  - { name: Reconciliation }
  - { name: Exceptions }
  - { name: Exports }
  - { name: Webhooks }
  - { name: Admin }
  - { name: Connectors }
  - { name: Copilot }
  - { name: Observability }
paths:
  /api/session:
    get:
      tags: [Auth]
      summary: Current session
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
              examples:
                default:
                  value:
                    user_id: 7a2f5f6c-2b7a-4a9d-9d1c-7e8b0a1c2d3e
                    email: ops@bank.example
                    roles: [tenant_admin]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/Internal" }

  /api/tenants:
    get:
      tags: [Tenants]
      summary: List tenants visible to caller
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Order"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Tenant" } }
              examples:
                default:
                  value:
                    data:
                      - { id: 1c...uuid, slug: fvihq-prod, name: FVI-HQ, environment: prod, ai_enabled: true }
                    page: { next_cursor: null, prev_cursor: null, has_more: false, limit: 50, count: 1 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Tenants]
      summary: Create tenant (platform_admin only)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TenantCreate" }
            examples:
              default: { value: { slug: acme-uat, name: Acme UAT, environment: uat } }
      responses:
        "201":
          description: Created
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            Idempotency-Replay: { $ref: "#/components/headers/IdempotencyReplay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tenant" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/recon/runs:
    get:
      tags: [Reconciliation]
      summary: List recon runs for a tenant (cursor-paginated)
      parameters:
        - { name: tenant_id, in: query, required: true, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, enum: [queued, running, completed, failed] } }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Order"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/ReconRun" } }
              examples:
                default:
                  value:
                    data:
                      - { id: run_..., recon_id: rec_..., status: completed, as_of: 2026-07-05, matched: 18422, breaks: 31 }
                    page: { next_cursor: eyJpZCI6InJ1bl8uLi4ifQ, has_more: true, limit: 200, count: 200 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "410": { $ref: "#/components/responses/Gone" }

  /api/recon/runs/{id}/matches:
    get:
      tags: [Reconciliation]
      summary: Get matches for a run (cursor-paginated)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Page" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/recon/runs/{id}/breaks:
    get:
      tags: [Exceptions]
      summary: Get open breaks for a run (cursor-paginated)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, enum: [open, assigned, in_review, resolved, closed] } }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Break" } }
              examples:
                default:
                  value:
                    data:
                      - { id: br_..., code: AMOUNT_MISMATCH, severity: high, assignee_id: null, sla_state: at_risk, opened_at: 2026-07-05T04:12:00Z }
                    page: { next_cursor: null, has_more: false, limit: 100, count: 1 }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/exports/audit:
    get:
      tags: [Exports]
      summary: Export audit log (CSV/XLSX/PDF)
      parameters:
        - { name: format, in: query, required: true, schema: { type: string, enum: [csv, xlsx, pdf] } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: OK (binary stream)
          content:
            text/csv: {}
            application/pdf: {}
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: {}
        "413": { $ref: "#/components/responses/PayloadTooLarge" }

  /api/exports/soc2-bundle:
    post:
      tags: [Exports]
      summary: Generate a SOC 2 evidence bundle (ZIP) — signed URL, 5–120 min TTL
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, period]
              properties:
                tenant_id: { type: string, format: uuid }
                period: { type: string, example: "2026Q2" }
                ttl_minutes: { type: integer, minimum: 5, maximum: 120, default: 30 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  signed_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
              examples:
                default:
                  value:
                    signed_url: https://.../soc2-2026Q2.zip?token=...
                    expires_at: 2026-07-05T10:00:00Z
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /api/webhooks/outbound/test:
    post:
      tags: [Webhooks]
      summary: Send a test webhook
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint_id, event]
              properties:
                endpoint_id: { type: string }
                event: { type: string, example: "break.opened" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              examples:
                default: { value: { delivery_id: del_..., http_status: 200, signature: "v1=...", latency_ms: 143 } }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/public/hooks/{provider}:
    post:
      tags: [Webhooks]
      summary: Inbound webhook (HMAC-signed)
      description: |
        See admin guide 08 for signature scheme; timing-safe verification is mandatory.
        This endpoint is naturally idempotent by `event_id` — retries are ack'd 200.
      parameters:
        - { name: provider, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/admin/audit:
    get:
      tags: [Admin]
      summary: Immutable audit log (cursor-paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 200 } }
        - { name: scope, in: query, schema: { type: string, enum: [platform, tenant] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }
              examples:
                default:
                  value:
                    data:
                      - { id: 01JZ..., ts: 2026-07-05T09:00:01Z, actor: user:..., action: signoff.locked, scope: tenant, tenant_id: ..., payload: { period: 2026-06 } }
                    page: { next_cursor: eyJ0cyI6IjIwMjYtMDctMDVUMDg6NTk6NTlaIn0, has_more: true, limit: 500, count: 500 }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/admin/break-glass:
    post:
      tags: [Admin]
      summary: Request break-glass grant (dual-control)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, reason, ttl_minutes]
              properties:
                tenant_id: { type: string, format: uuid }
                reason: { type: string, minLength: 10, maxLength: 500 }
                ttl_minutes: { type: integer, minimum: 5, maximum: 240 }
                scope: { type: string, enum: [read_only, read_write], default: read_only }
      responses:
        "202":
          description: Pending approval
          content:
            application/json:
              examples:
                default: { value: { grant_id: bg_..., status: pending_approval, approvers_notified: 2, expires_at: 2026-07-05T10:00:00Z } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/connectors/{name}/sync:
    post:
      tags: [Connectors]
      summary: Trigger a manual connector sync
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, enum: [sap, oracle, finacle, flexcube, tally] } }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Queued
          content:
            application/json:
              examples:
                default: { value: { job_id: job_01JZ..., status: queued, poll_url: /api/connectors/sap/jobs/job_01JZ... } }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "503": { $ref: "#/components/responses/Unavailable" }
        "504": { $ref: "#/components/responses/Timeout" }

  /api/connectors/{name}/health:
    get:
      tags: [Connectors]
      summary: Connector health (last sync, lag, error rate)
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/copilot/chat:
    post:
      tags: [Copilot]
      summary: Copilot chat completion (RBAC-scoped tools)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message, tenant_id]
              properties:
                message: { type: string, maxLength: 8000 }
                tenant_id: { type: string, format: uuid }
            examples:
              default: { value: { tenant_id: 11111111-1111-1111-1111-111111111111, message: "List my top 10 open breaks by amount." } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              examples:
                default:
                  value:
                    reply: "Here are the 10 largest open breaks…"
                    tool_calls: [ { name: list_breaks, status: ok } ]
                    usage: { prompt_tokens: 812, completion_tokens: 214 }
                    request_id: req_...
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/observability/health:
    get:
      tags: [Observability]
      summary: Health check + SLO status
      security: []
      responses:
        "200": { description: OK }
        "503": { $ref: "#/components/responses/Unavailable" }

components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
    oauth2ClientCredentials:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://fvi.lovable.app/api/public/oauth/token
          scopes:
            recon.read: Read recon runs, matches, breaks
            recon.write: Trigger recon runs, resolve breaks
            audit.read: Read audit log
            admin: Platform admin operations

  headers:
    XRequestId:
      description: Server-assigned request id (echoes `X-Request-Id` if provided).
      schema: { type: string }
    IdempotencyReplay:
      description: Present and `true` when the response is a replay of a prior idempotent call.
      schema: { type: string, enum: ["true"] }
    RetryAfter:
      description: Seconds to wait before retrying. Present on 429 and 503.
      schema: { type: integer, minimum: 0 }
    RateLimitLimit:
      description: Request budget for the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Remaining requests in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Seconds until the window resets.
      schema: { type: integer }

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size (max 500).
      schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `page.next_cursor`.
      schema: { type: string }
    Order:
      name: order
      in: query
      description: Sort direction on the deterministic key.
      schema: { type: string, enum: [asc, desc], default: desc }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated key (UUID/ULID, ≤255 ASCII chars) that makes this
        request safely retryable. Retries with the same key and body replay
        the original response; different body → 409 idempotency_conflict.
      schema: { type: string, maxLength: 255 }

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default:
              value:
                type: /errors/invalid-request
                title: Bad Request
                status: 400
                code: invalid_request
                detail: "Query param 'limit' must be between 1 and 500."
                instance: /api/recon/runs
                request_id: req_01JZ...
    Unauthorized:
      description: Bearer token missing/invalid
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default:
              value:
                type: /errors/unauthenticated
                title: Unauthorized
                status: 401
                code: unauthenticated
                detail: "JWT expired at 2026-07-05T09:11:04Z"
                instance: /api/session
    Forbidden:
      description: Caller lacks the required role/scope
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default:
              value: { type: /errors/forbidden, title: Forbidden, status: 403, code: forbidden, detail: "Role 'tenant_analyst' cannot perform this operation.", instance: /api/exports/soc2-bundle }
    NotFound:
      description: Resource not found or invisible under RLS
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default: { value: { type: /errors/not-found, title: Not Found, status: 404, code: not_found, detail: "Run does not exist or is not visible to this tenant.", instance: /api/recon/runs/... } }
    Gone:
      description: Cursor expired or resource sunset
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default: { value: { type: /errors/gone, title: Gone, status: 410, code: cursor_expired, detail: "Cursor issued > 24h ago; restart pagination.", instance: /api/recon/runs } }
    Conflict:
      description: Version/state conflict
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    IdempotencyConflict:
      description: Idempotency-Key reused with a different body
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default: { value: { type: /errors/idempotency-conflict, title: Conflict, status: 409, code: idempotency_conflict, detail: "Idempotency-Key reused with a different body hash.", instance: /api/tenants } }
    PayloadTooLarge:
      description: Body exceeds per-endpoint size cap
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unprocessable:
      description: Validation / domain-rule failure
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default:
              value:
                type: /errors/unprocessable
                title: Unprocessable Entity
                status: 422
                code: unprocessable
                instance: /api/tenants
                errors:
                  - { path: slug, code: pattern, message: "must match ^[a-z0-9-]{2,80}$" }
    RateLimited:
      description: Bucket exhausted
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default: { value: { type: /errors/rate-limited, title: Too Many Requests, status: 429, code: rate_limited, detail: "Bucket 'copilot:chat:tenant:...' — 60/60s exhausted.", instance: /api/copilot/chat, retry_after_seconds: 18 } }
    Internal:
      description: Unhandled server error
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unavailable:
      description: Dependency unreachable
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            default: { value: { type: /errors/unavailable, title: Service Unavailable, status: 503, code: unavailable, detail: "SAP RFC endpoint unreachable; last success 2026-07-05T02:11Z.", instance: /api/connectors/sap/sync, retry_after_seconds: 60 } }
    Timeout:
      description: Upstream timeout
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      description: RFC 7807 Problem Details, extended with FVI members.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri-reference, description: "Stable machine id URI for the error class." }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        detail: { type: string }
        instance: { type: string }
        code:
          type: string
          enum: [invalid_request, unauthenticated, forbidden, not_found, method_not_allowed,
                 conflict, idempotency_conflict, idempotency_in_progress, gone, cursor_expired,
                 payload_too_large, unsupported_media, unprocessable, rate_limited,
                 internal, unavailable, timeout]
        request_id: { type: string }
        trace_id: { type: string }
        retry_after_seconds: { type: integer, minimum: 0 }
        errors:
          type: array
          items:
            type: object
            required: [path, code, message]
            properties:
              path: { type: string, description: "JSON pointer or dotted path to the offending field." }
              code: { type: string }
              message: { type: string }
    PageInfo:
      type: object
      required: [has_more, limit, count]
      properties:
        next_cursor: { type: [string, "null"] }
        prev_cursor: { type: [string, "null"] }
        has_more: { type: boolean }
        limit: { type: integer, minimum: 1, maximum: 500 }
        count: { type: integer, minimum: 0 }
    Page:
      type: object
      required: [data, page]
      properties:
        data: { type: array, items: {} }
        page: { $ref: "#/components/schemas/PageInfo" }
    Session:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string, format: email }
        roles: { type: array, items: { type: string } }
    Tenant:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        environment: { type: string, enum: [prod, uat, dev] }
        ai_enabled: { type: boolean }
    TenantCreate:
      type: object
      required: [slug, name, environment]
      properties:
        slug: { type: string, pattern: "^[a-z0-9-]{2,80}$" }
        name: { type: string, maxLength: 120 }
        environment: { type: string, enum: [prod, uat, dev] }
    ReconRun:
      type: object
      properties:
        id: { type: string, format: uuid }
        recon_id: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, completed, failed] }
        as_of: { type: string, format: date }
        matched: { type: integer, minimum: 0 }
        breaks: { type: integer, minimum: 0 }
    Break:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        severity: { type: string, enum: [low, medium, high, critical] }
        assignee_id: { type: [string, "null"], format: uuid }
        sla_state: { type: string, enum: [ok, at_risk, breached] }
        opened_at: { type: string, format: date-time }
    AuditEntry:
      type: object
      properties:
        id: { type: string }
        ts: { type: string, format: date-time }
        actor: { type: string }
        action: { type: string }
        scope: { type: string, enum: [platform, tenant] }
        tenant_id: { type: [string, "null"], format: uuid }
        payload: { type: object, additionalProperties: true }
