# Source of truth: docs/openapi.yaml in
# https://github.com/Declade/dual-sandbox-architecture
#
# Regenerate this file by copying the upstream copy:
#   curl -fsSL https://raw.githubusercontent.com/Declade/dual-sandbox-architecture/main/docs/openapi.yaml \
#     > public/openapi.yaml
#
# Drift between this copy and the upstream is a known v1 risk. CI does
# not auto-sync today. When the gateway team ships a major API change,
# follow up with a sync PR on theveil-website.
#
# Note on legacy literals: this file mirrors the upstream gateway spec
# verbatim. References to "DSA" and the `dsa_*` schema/example field
# names predate the Lucairn rebrand (Stage 3 gateway rename pending).
# The Lucairn-branded surface lives in /docs/* and on the gateway response
# headers (`lcr_live_*`); the wire schema names will change in a future
# upstream release.
openapi: 3.1.0

info:
  title: DSA Gateway API
  version: "1.0"
  description: |
    The Dual-Sandbox Architecture (DSA) Gateway API. The Gateway is the
    only entry point for client traffic — it orchestrates the full
    split-knowledge pipeline (Bridge tokenisation → PII sanitisation →
    Sandbox B inference) while ensuring that identity data and AI
    inferences never coexist in the same downstream service.

    ## Authentication

    Most endpoints require an `x-api-key` header containing a DSA API key
    (prefix `dsa_`). Keys are issued via `POST /api/v1/register`.

    ## Tier system

    | Tier       | Monthly requests | Audit export | Veil certificates |
    |------------|-----------------|--------------|-------------------|
    | free       | 500             | No           | No                |
    | pro        | 50 000          | Yes          | Yes               |
    | enterprise | Unlimited       | Yes          | Yes               |

    Pro is BYOK only. Managed AI requires Enterprise. Pro includes
    L3 LLM PII Shield and the compliance endpoints.

    ## Error format

    All error responses share the same JSON schema (`DSAError`). Errors
    include a machine-readable `error` code for SDK matching and an
    optional `hint` with an actionable suggestion.

  contact:
    name: DSA Support
    url: https://your-dsa-instance

servers:
  - url: https://your-dsa-instance
    description: Customer-deployed DSA instance

tags:
  - name: Auth
    description: Self-service registration (no authentication required)
  - name: Inference
    description: Split-knowledge inference proxy
  - name: Account
    description: Usage and audit trail
  - name: Veil
    description: Cryptographic isolation certificates (Veil Protocol)
  - name: WellKnown
    description: Public key discovery
  - name: Session
    description: Browser-session login for the portal UI
  - name: Admin
    description: Back-office API protected by `X-Admin-Key`
  - name: Compliance
    description: GDPR / EU AI Act endpoints (posture, DPIA, DSAR, evidence pack)
  - name: Website
    description: Scoped service-key endpoints for the marketing site
  - name: MCP
    description: MCP-compatible inference with per-key system-prompt policy
  - name: OpenAI
    description: OpenAI Chat Completions-compatible inference
  - name: Internal
    description: |
      Operator-facing endpoints (health, readiness, invariant). Published
      for completeness but marked `x-internal: true` — not intended for
      public API consumers.

# ---------------------------------------------------------------------------
# Security schemes
# ---------------------------------------------------------------------------

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        DSA API key issued via `POST /api/v1/register`. Keys have the
        prefix `dsa_` and encode tier, quota, and BYOK settings.

    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: dsa_ (hex)
      description: |
        Equivalent to `ApiKeyAuth` but passed as `Authorization: Bearer <dsa_...>`.
        Used by the Anthropic-SDK and OpenAI-SDK compatibility endpoints
        so that stock SDKs work unchanged.

    AdminKeyAuth:
      type: apiKey
      in: header
      name: X-Admin-Key
      description: |
        Operator-only admin key. Grants access to every `/api/v1/admin/*`
        endpoint. Rotates independently from customer API keys. Unset or
        wrong value → 401 from `adminKeyGuard`.

    SessionAuth:
      type: apiKey
      in: cookie
      name: dsa_session
      description: |
        Browser-session cookie set by `POST /api/v1/auth/login`. HTTPOnly,
        SameSite=Strict, 1-hour TTL. Scoped to the portal UI only.

    WebsiteKeyAuth:
      type: apiKey
      in: header
      name: x-website-key
      description: |
        Scoped service key shared only with the marketing site backend.
        Gates `/api/v1/website/*`. Configured via `DSA_WEBSITE_SERVICE_KEY`.

  # -------------------------------------------------------------------------
  # Reusable schemas
  # -------------------------------------------------------------------------

  schemas:

    # ----- Error ----------------------------------------------------------

    DSAError:
      type: object
      required:
        - error
        - message
      properties:
        error:
          type: string
          description: Machine-readable error code for SDK matching.
          examples:
            - invalid_json
            - missing_api_key
            - quota_exceeded
        message:
          type: string
          description: Human-readable explanation of the error.
          examples:
            - "Could not parse request body as JSON."
        hint:
          type: string
          description: Actionable suggestion (omitted when not applicable).
          examples:
            - "Check for trailing commas or missing quotes."
        retry_after_seconds:
          type: integer
          description: >
            Seconds to wait before retrying (transient errors only).
            Also reflected in the `Retry-After` response header.
          examples:
            - 30
        used:
          type: integer
          description: >
            Current usage count (present on `quota_exceeded` errors only).
          examples:
            - 50000
        limit:
          type: integer
          description: >
            Monthly request limit (present on `quota_exceeded` errors only).
          examples:
            - 50000

    # ----- Legacy-removed error ------------------------------------------

    DSALegacyRemovedError:
      type: object
      description: >
        Response body shape for `410 Gone` responses from removed legacy
        endpoints. Distinct from `DSAError` because the handler intentionally
        returns a narrative `error` string plus a `migration` URL rather than
        the machine-readable `error` + human-readable `message` pair.
        Produced by `handleLegacyEndpointRemoved`
        (`services/gateway/internal/api/handler.go:307-311`).
      required:
        - error
        - migration
      properties:
        error:
          type: string
          description: Narrative message explaining the removal and next action.
          examples:
            - "endpoint removed — use /api/v1/proxy/messages for full split-knowledge pipeline"
        migration:
          type: string
          format: uri
          description: URL to the migration documentation.
          examples:
            - "https://lucairn.eu/docs/migration/proxy-messages"

    # ----- Registration ---------------------------------------------------

    RegisterRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: Customer contact email. Used as idempotency key — a second
            registration with the same email returns the existing key.
          examples:
            - alice@example.com
        company:
          type: string
          description: Company name (informational, not validated).
          examples:
            - Acme GmbH
        anthropic_key:
          type: string
          description: |
            BYOK (Bring Your Own Key) Anthropic API key. Must start with
            `sk-ant-`. Omit to use DSA Managed AI (DSA's shared key).
          examples:
            - "sk-ant-api03-xxxxxxxxxxxxxxxxxxxx"

    RegisterResponse:
      type: object
      required:
        - dsa_api_key
        - tier
        - managed_ai
        - message
      properties:
        dsa_api_key:
          type: string
          description: DSA API key. Store securely — it is shown only once.
          examples:
            - "dsa_4f3a1b2c8d9e0f1a2b3c4d5e6f7a8b9c"
        tier:
          type: string
          enum: [free, pro, enterprise]
          description: Tier assigned to this key. New registrations always start on `free`.
          examples:
            - free
        monthly_limit:
          type: integer
          description: Maximum requests allowed per calendar month.
          examples:
            - 500
        managed_ai:
          type: boolean
          description: >
            `true` when DSA provides the LLM API key (Managed AI).
            `false` when the customer supplied their own Anthropic key (BYOK).
          examples:
            - true
        message:
          type: string
          description: Setup instructions including the proxy base URL.
          examples:
            - "Your DSA API key is ready with Managed AI. DSA will provide the LLM
              key. Set your Anthropic base URL to
              https://your-dsa-instance/api/v1/proxy/messages and use this key as
              x-api-key."

    # ----- Proxy request --------------------------------------------------

    PIIAnnotation:
      type: object
      description: Ground-truth PII annotation for proving-ground mode.
      required:
        - type
        - value
        - start
        - end
      properties:
        type:
          type: string
          description: PII entity type (e.g. PERSON, EMAIL_ADDRESS, PHONE_NUMBER).
          examples:
            - PERSON
        value:
          type: string
          description: The PII text value as it appears in the context.
          examples:
            - "Hans Müller"
        start:
          type: integer
          description: Start character offset within the context field value.
          examples:
            - 0
        end:
          type: integer
          description: End character offset (exclusive).
          examples:
            - 11

    ProxyRequest:
      type: object
      required:
        - prompt_template
        - context
      properties:
        prompt_template:
          type: string
          description: |
            Prompt template with `{variable}` placeholders. The template is
            **not** sanitised — it is the caller's responsibility to keep it
            free of PII. Placeholders are replaced with sanitised `context`
            values inside Sandbox B.
          examples:
            - "Analyse customer: {customer_data}. Rules: {rules}"
        context:
          type: object
          additionalProperties:
            type: string
          description: |
            Key-value pairs whose values pass through the full DSA pipeline
            (Bridge tokenisation → PII sanitisation → Sandbox B inference).
            At least one key-value pair is required. Keys starting with `__`
            are reserved and will be stripped.
          examples:
            - customer_data: "Customer ID: 12345, Risk Score: 8.2"
              rules: "Flag if risk score > 7.0"
        model:
          type: string
          description: >
            LLM model identifier. Defaults to the instance's configured
            model when omitted.
          examples:
            - "claude-3-5-sonnet-20241022"
        max_tokens:
          type: integer
          description: Maximum tokens in the LLM response. Passed to Sandbox B as-is.
          examples:
            - 1024
        temperature:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Sampling temperature. Passed to Sandbox B as-is.
          examples:
            - 0.7
        mode:
          type: string
          enum: [live, proving_ground]
          default: live
          description: |
            `live` (default) — standard production inference.

            `proving_ground` — evaluates PII sanitiser accuracy against
            caller-supplied ground-truth annotations. Requires `activity_id`.
            Ground-truth data never leaves the Gateway.
          examples:
            - live
        activity_id:
          type: string
          description: >
            Processing activity identifier. Required when `mode` is
            `proving_ground`. Used for compliance tracing.
          examples:
            - "activity_risk_scoring_v2"
        ground_truth:
          type: object
          additionalProperties:
            type: array
            items:
              $ref: "#/components/schemas/PIIAnnotation"
          description: |
            Per-field PII annotations for proving-ground evaluation.
            Stripped from the request before any downstream call —
            never reaches the Sanitiser, Bridge, or Sandbox B.
          examples:
            - customer_data:
                - type: PERSON
                  value: "Hans Müller"
                  start: 0
                  end: 11
        relink_response:
          type: boolean
          description: |
            Whether to replace PII placeholders in the AI response with
            their original values. Defaults to `true` (customer submitted
            the data and expects readable output).
          default: true
          examples:
            - true

    # ----- Proxy response -------------------------------------------------

    ComplianceTraceStep:
      type: object
      properties:
        step:
          type: integer
          examples:
            - 1
        component:
          type: string
          examples:
            - "PII Sanitizer"
        network:
          type: string
          examples:
            - "dsa-identity"
        action:
          type: string
          examples:
            - "Sanitized context values via NER + phonetics"
        detail:
          type: string
          examples:
            - "Processed 2 context fields"
        data_seen:
          type: string
          examples:
            - "token + context keys: [customer_data, rules]"
        pii_sent:
          type: boolean
          examples:
            - false

    ComplianceTrace:
      type: object
      properties:
        token_request_id:
          type: string
          examples:
            - "req_4f3a1b2c8d9e"
        job_request_id:
          type: string
          examples:
            - "req_4f3a1b2c8d9e"
        job_id:
          type: string
          examples:
            - "job_9b8c7d6e5f4a"
        timestamp:
          type: string
          format: date-time
          examples:
            - "2026-03-28T09:00:00Z"
        total_duration_ms:
          type: integer
          examples:
            - 834
        steps:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceTraceStep"
        network_isolation:
          type: object
          properties:
            sandbox_a_to_sandbox_b:
              type: string
              examples:
                - "BLOCKED (dsa-identity cannot reach dsa-ai)"
            sandbox_b_to_sandbox_a:
              type: string
              examples:
                - "BLOCKED (dsa-ai cannot reach dsa-identity)"
            gateway_role:
              type: string
              examples:
                - "Orchestrator only -- never forwards both identity and token to same service"
        data_exposure_summary:
          type: object
          properties:
            bridge_saw:
              type: string
              examples:
                - "customerID (opaque identifier from API key)"
            sanitizer_saw:
              type: string
              examples:
                - "2 context field values (NER + phonetics applied)"
            sandbox_b_saw:
              type: string
              examples:
                - "opaque token + prompt template + 2 sanitized context fields"
            llm_saw:
              type: string
              examples:
                - "rendered prompt with 2 sanitized context fields"
            pii_in_ai:
              type: boolean
              examples:
                - false
            identity_in_ai:
              type: boolean
              examples:
                - false

    VeilHint:
      type: object
      properties:
        status:
          type: string
          enum: [available]
          examples:
            - available
        certificate_url:
          type: string
          examples:
            - "/api/v1/veil/certificate/req_4f3a1b2c8d9e"
        summary_url:
          type: string
          examples:
            - "/api/v1/veil/certificate/req_4f3a1b2c8d9e/summary"

    GroundTruthEvaluation:
      type: object
      description: Sanitiser accuracy result (proving-ground mode only).
      properties:
        total_annotations:
          type: integer
          examples:
            - 3
        true_positives:
          type: integer
          examples:
            - 3
        false_negatives:
          type: integer
          examples:
            - 0
        false_positives:
          type: integer
          examples:
            - 0
        detection_rate:
          type: number
          format: float
          minimum: 0
          maximum: 1
          examples:
            - 1.0
        matches:
          type: array
          items:
            type: object
            properties:
              annotation_type:
                type: string
              annotation_value:
                type: string
              redacted_as:
                type: string
        missed:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              type:
                type: string
              value:
                type: string
        extras:
          type: array
          items:
            type: object
            properties:
              placeholder:
                type: string
              original:
                type: string

    ProxyResponse:
      type: object
      required:
        - status
        - compliance_trace
      properties:
        status:
          type: string
          enum:
            - JOB_STATUS_COMPLETED
            - JOB_STATUS_FAILED
            - processing
          description: |
            `JOB_STATUS_COMPLETED` — inference finished successfully.
            `JOB_STATUS_FAILED` — inference failed; see `error_message`.
            `processing` — result not yet ready (returned as HTTP 202 when
            `wait_timeout` elapses before completion).
          examples:
            - JOB_STATUS_COMPLETED
        result:
          description: >
            AI response payload. Shape depends on the LLM model and
            prompt. May be a JSON object, array, or plain string.
          examples:
            - text: "Risk level HIGH — score 8.2 exceeds threshold 7.0."
        model_used:
          type: string
          description: Model identifier used for this request.
          examples:
            - "claude-3-5-sonnet-20241022"
        latency_ms:
          type: integer
          description: Total inference latency in milliseconds (Sandbox B perspective).
          examples:
            - 640
        error_message:
          type: string
          description: >
            Error detail from the inference service (present when
            `status` is `JOB_STATUS_FAILED`).
          examples:
            - "model returned an empty response"
        dlp_redacted:
          type: boolean
          description: >
            `true` when the Gateway's outbound DLP scanner detected and
            redacted PII patterns in the AI response.
          examples:
            - false
        relinked:
          type: boolean
          description: >
            `true` when PII placeholders in the AI response were replaced
            with original values (controlled by `relink_response`).
          examples:
            - true
        request_id:
          type: string
          description: >
            Stable request identifier. Use this with the Veil endpoints
            to retrieve the isolation certificate. Present only for
            Pro / Enterprise tiers when Veil is enabled.
          examples:
            - "req_4f3a1b2c8d9e"
        veil:
          $ref: "#/components/schemas/VeilHint"
        compliance_trace:
          $ref: "#/components/schemas/ComplianceTrace"
        ground_truth_evaluation:
          $ref: "#/components/schemas/GroundTruthEvaluation"

    # ----- Usage ----------------------------------------------------------

    UsageResponse:
      type: object
      required:
        - customer_id
        - tier
        - used
        - managed_ai
        - audit_export
      properties:
        customer_id:
          type: string
          description: Opaque customer identifier derived from the email hash.
          examples:
            - "cust_4f3a1b2c8d9e0f1a"
        tier:
          type: string
          enum: [free, pro, enterprise]
          examples:
            - pro
        monthly_limit:
          description: >
            Maximum requests per month. `"unlimited"` for Enterprise tier.
          oneOf:
            - type: integer
            - type: string
              enum: [unlimited]
          examples:
            - 50000
        used:
          type: integer
          description: Requests consumed in the current calendar month.
          examples:
            - 1234
        remaining:
          description: >
            Requests remaining. `"unlimited"` for Enterprise tier.
          oneOf:
            - type: integer
            - type: string
              enum: [unlimited]
          examples:
            - 48766
        managed_ai:
          type: boolean
          description: Whether DSA provides the LLM key for this customer.
          examples:
            - false
        audit_export:
          type: boolean
          description: Whether audit trail export is enabled for this tier.
          examples:
            - true

    # ----- Audit export ---------------------------------------------------

    AuditEntry:
      type: object
      properties:
        timestamp:
          type: string
          format: date-time
          examples:
            - "2026-03-28T09:01:42Z"
        event_type:
          type: string
          description: >
            Structured event type from the Gateway audit pipeline.
            Common values: `PROXY_TOKEN_GENERATED`, `PROXY_SANITIZER_APPLIED`,
            `PROXY_INFERENCE_SUBMITTED`, `PROXY_DLP_OUTBOUND_MATCH`,
            `PROVING_GROUND_EVALUATION`.
          examples:
            - PROXY_SANITIZER_APPLIED
        actor:
          type: string
          description: >
            Customer ID or hashed token string (`token:<sha256>`). Identity
            is never stored in plaintext in the audit log.
          examples:
            - "cust_4f3a1b2c8d9e0f1a"
        details:
          type: string
          description: JSON-encoded event payload. Contents vary by event type.
          examples:
            - '{"request_id":"req_4f3a1b2c8d9e"}'
        request_id:
          type: string
          description: Inference request identifier (when applicable).
          examples:
            - "req_4f3a1b2c8d9e"

    AuditExportResponse:
      type: object
      required:
        - customer_id
        - tier
        - period
        - events
        - total_events
      properties:
        customer_id:
          type: string
          examples:
            - "cust_4f3a1b2c8d9e0f1a"
        tier:
          type: string
          examples:
            - pro
        period:
          type: string
          description: Date range of the exported events (inclusive).
          examples:
            - "2026-02-27 to 2026-03-28"
        events:
          type: array
          items:
            $ref: "#/components/schemas/AuditEntry"
        total_events:
          type: integer
          examples:
            - 42

    AuditViewEvent:
      type: object
      description: >
        A single audit event surfaced through the org-scoped account
        viewer. Raw payload bytes are NOT returned — only an allow-listed
        `payload_summary` that excludes any field that could contain PII.
      required: [timestamp, event_type, actor]
      properties:
        timestamp:
          type: string
          format: date-time
          examples:
            - "2026-04-29T12:34:56Z"
        event_type:
          type: string
          examples:
            - PROXY_TOKEN_GENERATED
        actor:
          type: string
          description: >
            Customer ID for customer-attributed events, or a hashed token
            (`token:<hex>`) for inference events.
          examples:
            - "cust_4f3a1b2c8d9e0f1a"
        request_id:
          type: string
          examples:
            - "req_4f3a1b2c8d9e"
        payload_summary:
          type: object
          additionalProperties:
            type: string
          description: >
            Allow-listed non-PII summary keys (request_id, job_id, model,
            stop_reason, input_tokens, output_tokens, latency_ms,
            error_type, match_count, detected_types, severity,
            activity_id, vertical). Any other payload key is dropped.

    AuditViewResponse:
      type: object
      required: [events, page, page_size, total, has_more, org_id, source, period]
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/AuditViewEvent"
        page:
          type: integer
          minimum: 1
        page_size:
          type: integer
          minimum: 1
          maximum: 200
        total:
          type: integer
        has_more:
          type: boolean
        org_id:
          type: string
        source:
          type: string
          enum: [audit_service, audit_service+memory_buffer, memory_buffer, none]
        period:
          type: string
          description: RFC3339 lower/upper bound of the lookback window.
        truncated:
          type: boolean
          description: >
            True when the upstream audit service returned the maximum
            number of events the query allows; older events in the
            window may have been clipped.

    # ----- Veil keys manifest --------------------------------------------

    VeilKeyEntry:
      type: object
      required: [service_id, public_key, algorithm]
      properties:
        service_id:
          type: string
          examples:
            - "dsa-witness"
        key_id:
          type: string
          description: >
            Stable identifier for this specific key material. Used to
            distinguish rotated keys that share a service_id — e.g.
            "witness_v1" vs "witness_v2" after rotation.
          examples:
            - "witness_v1"
        public_key:
          type: string
          description: Hex-encoded Ed25519 public key.
          examples:
            - "a1b2c3d4e5f6..."
        purpose:
          type: string
          examples:
            - "Certificate signing"
        algorithm:
          type: string
          enum: [Ed25519]
          examples:
            - Ed25519
        key_state:
          type: string
          enum: [active, retired]
          description: >
            `active` keys are in use by the named service. `retired`
            keys appear so verifiers can still authenticate historical
            certificates that were signed under the previous key.
          examples:
            - active
        not_before:
          type: string
          format: date-time
          description: >
            RFC3339 earliest time this key was valid. Typically absent
            on active keys; present on retired entries.
        not_after:
          type: string
          format: date-time
          description: >
            RFC3339 latest time this key was valid. Present on retired
            entries; absent on active keys.

    VeilManifestSignature:
      type: object
      required: [key_id, algorithm, signature]
      description: >
        One of the two Ed25519 signatures that cover the manifest's
        canonical body in dual-sign mode.
      properties:
        key_id:
          type: string
          description: Stable identifier for the signing key.
          examples:
            - "gateway_manifest_v1"
        algorithm:
          type: string
          enum: [Ed25519]
        signature:
          type: string
          description: Hex-encoded Ed25519 signature over the canonical bytes.
          examples:
            - "c3d4e5f6a7b8..."

    VeilKeysManifest:
      type: object
      required: [issuer, version, supported_protocol_versions, keys, signed_at]
      description: >
        Public key manifest for the Veil protocol. In production, the
        `signatures` object carries two independent Ed25519 signatures
        over the same canonical bytes — a gateway-held signature
        (revocable "alive + healthy" signal) and a witness-rooted
        signature (witness-rooted, out-of-band pre-signed). Both MUST validate for
        the manifest to be accepted.

        Verifier recipe: strip the `signatures` object and the
        `served_at` field from the response, canonicalize the remaining
        fields under the pkg/veil canonicalizer (sorted keys, no
        whitespace), then Ed25519-verify each of
        `signatures.gateway.signature` and `signatures.witness.signature`
        against those bytes.

        Development-mode deployments (DSA_ENV != production, and when
        the witness-signed blob is absent on disk) fall back to a
        legacy single-signature shape with top-level `signature`,
        `signing_key_id`, and `signing_algorithm` fields and no
        `signatures` object. Production deployments always emit the
        dual-sign shape.
      properties:
        issuer:
          type: string
          examples:
            - "DSA Veil Witness"
        version:
          type: integer
          examples:
            - 1
        supported_protocol_versions:
          type: array
          items:
            type: integer
          examples:
            - [1, 2]
        keys:
          type: array
          items:
            $ref: "#/components/schemas/VeilKeyEntry"
        signed_at:
          type: string
          format: date-time
          description: >
            RFC3339 ceremony time. Stable across requests and INSIDE
            the bytes covered by both signatures.
          examples:
            - "2026-04-24T12:00:00Z"
        signatures:
          type: object
          required: [gateway, witness]
          description: >
            Two independent Ed25519 signatures over the canonical body.
            Required in production; absent in legacy dev-mode responses.
          properties:
            gateway:
              $ref: "#/components/schemas/VeilManifestSignature"
            witness:
              $ref: "#/components/schemas/VeilManifestSignature"
        served_at:
          type: string
          format: date-time
          description: >
            RFC3339 request time. Recomputed per request and EXCLUDED
            from the bytes covered by the signatures.

  # -------------------------------------------------------------------------
  # Reusable responses
  # -------------------------------------------------------------------------

  responses:

    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DSAError"
          examples:
            missing_api_key:
              value:
                error: missing_api_key
                message: "The x-api-key header is required."
                hint: "Get a key at https://lucairn.eu/account/signup."
            invalid_api_key:
              value:
                error: invalid_api_key
                message: "The x-api-key header is not a valid DSA API key."
                hint: "Keys start with 'dsa_'. Get one at https://lucairn.eu/account/signup."

    Forbidden:
      description: License expired or tier insufficient.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DSAError"
          examples:
            license_expired:
              value:
                error: license_expired
                message: "DSA license has expired."
                hint: "Contact your DSA administrator to renew the license."
            tier_insufficient:
              value:
                error: tier_insufficient
                message: "This endpoint requires pro tier or above. Your tier: free."
                hint: "Contact sales to upgrade."

    MethodNotAllowed:
      description: HTTP method not supported by this endpoint.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DSAError"
          examples:
            method_not_allowed:
              value:
                error: method_not_allowed
                message: "GET is not supported. Use POST."

    AdminUnauthorized:
      description: Missing or invalid `X-Admin-Key` header.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DSAError"
          examples:
            admin_unauthorized:
              value:
                error: admin_unauthorized
                message: "Valid admin key is required."

    RateLimited:
      description: |
        Rate limit exhausted. The `Retry-After` response header carries
        the number of seconds the client should wait before retrying.
        Specific limits (per-IP, per-customer reveal, etc.) are
        documented in the endpoint description.
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DSAError"
          examples:
            rate_limited:
              value:
                error: rate_limited
                message: "Too many requests. Try again later."
                retry_after_seconds: 60

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

paths:

  # -------------------------------------------------------------------------
  # POST /api/v1/register
  # -------------------------------------------------------------------------

  /api/v1/register:
    post:
      operationId: register
      tags: [Auth]
      summary: Self-service free-tier signup
      description: |
        Creates a new DSA API key for the provided email address. All new
        registrations start on the **free** tier (500 requests/month).

        If an API key already exists for the supplied email, the existing key
        is returned without creating a duplicate.

        **Rate limit:** 5 registrations per source IP per hour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterRequest"
            examples:
              managed_ai:
                summary: Managed AI (no BYOK key)
                value:
                  email: alice@example.com
                  company: Acme GmbH
              byok:
                summary: BYOK — customer supplies Anthropic key
                value:
                  email: bob@example.com
                  company: Beta Corp
                  anthropic_key: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxx"
      responses:
        "201":
          description: API key created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegisterResponse"
              examples:
                managed_ai:
                  value:
                    dsa_api_key: "dsa_4f3a1b2c8d9e0f1a2b3c4d5e6f7a8b9c"
                    tier: free
                    monthly_limit: 500
                    managed_ai: true
                    message: "Your DSA API key is ready with Managed AI. ..."
        "200":
          description: >
            Email already registered — existing key returned. Same schema as
            201 but `monthly_limit` and `managed_ai` may be omitted if the
            key pre-dates this field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegisterResponse"
        "400":
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                invalid_json:
                  value:
                    error: invalid_json
                    message: "Could not parse request body as JSON."
                    hint: "Check for trailing commas or missing quotes."
                invalid_email:
                  value:
                    error: invalid_field
                    message: "Field 'email' has an invalid value: valid email is required"
                invalid_anthropic_key:
                  value:
                    error: invalid_field
                    message: "Field 'anthropic_key' has an invalid value: must start with sk-ant-"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Registration rate limit exceeded for this IP.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds before another registration attempt is permitted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                registration_limit:
                  value:
                    error: registration_limit
                    message: "Maximum registrations per IP exceeded. Try again in 60 minutes."

  # -------------------------------------------------------------------------
  # POST /api/v1/proxy/messages
  # -------------------------------------------------------------------------

  /api/v1/proxy/messages:
    post:
      operationId: proxyMessages
      tags: [Inference]
      summary: Split-knowledge inference proxy
      description: |
        The primary inference endpoint. Executes the full DSA split-knowledge
        pipeline:

        1. **API key auth** — customer profile and tier loaded.
        2. **Rate & quota check** — per-minute and monthly limits enforced.
        3. **Bridge tokenisation** — opaque pseudonymous token generated; only
           customer ID crosses this boundary (no context data).
        4. **PII sanitisation** — context values passed through NER + phonetics.
           FAIL CLOSED: if the sanitiser is configured but unavailable, the
           request is rejected rather than forwarding unsanitised PII.
        5. **Sandbox B inference** — token + sanitised context forwarded to the
           AI service. Sandbox B cannot reach Sandbox A.
        6. **DLP scan** — outbound AI response scanned; PII patterns redacted.
        7. **Re-link** — PII placeholders replaced with originals (default on).

        The `compliance_trace` in the response documents exactly what each
        component received, suitable for GDPR Article 30 records.

        **Request size limit:** 4 MB.

        **Poll timeout:** if inference is not complete within the configured
        `wait_timeout` (default 30 s), HTTP 202 is returned with
        `"status": "processing"`.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProxyRequest"
            examples:
              basic:
                summary: Basic live inference
                value:
                  prompt_template: "Analyse this customer: {customer_data}. Rules: {rules}"
                  context:
                    customer_data: "Customer ID: 12345, Risk Score: 8.2"
                    rules: "Flag if risk score > 7.0"
                  model: "claude-3-5-sonnet-20241022"
                  max_tokens: 1024
              proving_ground:
                summary: Proving-ground PII accuracy test
                value:
                  prompt_template: "Summarise: {note}"
                  context:
                    note: "Patient Hans Müller, DOB 1978-03-15, has hypertension."
                  mode: proving_ground
                  activity_id: activity_medical_notes_v1
                  ground_truth:
                    note:
                      - type: PERSON
                        value: "Hans Müller"
                        start: 8
                        end: 19
                      - type: DATE_OF_BIRTH
                        value: "1978-03-15"
                        start: 26
                        end: 36
      responses:
        "200":
          description: Inference completed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProxyResponse"
              examples:
                completed:
                  value:
                    status: JOB_STATUS_COMPLETED
                    result:
                      text: "Risk level HIGH — score 8.2 exceeds threshold 7.0."
                    model_used: "claude-3-5-sonnet-20241022"
                    latency_ms: 640
                    relinked: true
                    request_id: "req_4f3a1b2c8d9e"
                    veil:
                      status: available
                      certificate_url: "/api/v1/veil/certificate/req_4f3a1b2c8d9e"
                      summary_url: "/api/v1/veil/certificate/req_4f3a1b2c8d9e/summary"
                    compliance_trace:
                      token_request_id: "req_4f3a1b2c8d9e"
                      job_request_id: "req_4f3a1b2c8d9e"
                      job_id: "job_9b8c7d6e5f4a"
                      timestamp: "2026-03-28T09:00:00Z"
                      total_duration_ms: 834
                      steps: []
                      network_isolation:
                        sandbox_a_to_sandbox_b: "BLOCKED (dsa-identity cannot reach dsa-ai)"
                        sandbox_b_to_sandbox_a: "BLOCKED (dsa-ai cannot reach dsa-identity)"
                        gateway_role: "Orchestrator only -- never forwards both identity and token to same service"
                      data_exposure_summary:
                        bridge_saw: "customerID (opaque identifier from API key)"
                        sanitizer_saw: "2 context field values (NER + phonetics applied)"
                        sandbox_b_saw: "opaque token + prompt template + 2 sanitized context fields"
                        llm_saw: "rendered prompt with 2 sanitized context fields"
                        pii_in_ai: false
                        identity_in_ai: false
        "202":
          description: >
            Inference still in progress. Poll again or retrieve the result
            later using the `job_id` from the compliance trace.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [processing]
                  job_id:
                    type: string
                    examples:
                      - "job_9b8c7d6e5f4a"
        "400":
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                invalid_json:
                  value:
                    error: invalid_json
                    message: "Could not parse request body as JSON."
                    hint: "Check for trailing commas or missing quotes."
                missing_prompt_template:
                  value:
                    error: missing_field
                    message: "Required field 'prompt_template' is missing."
                empty_context:
                  value:
                    error: empty_context
                    message: "Context must contain at least one key-value pair."
                    hint: "Context values are the data sent through PII sanitization."
                prompt_template_pii:
                  value:
                    error: prompt_template_pii_detected
                    message: "prompt_template contains PII patterns (emails, phones, IBANs, SSNs). Move personal data into the context object where it will be sanitized."
                invalid_mode:
                  value:
                    error: invalid_field
                    message: "Field 'mode' has an invalid value: must be 'live' or 'proving_ground'"
                request_too_large:
                  value:
                    error: request_too_large
                    message: "Request body exceeds 4MB limit."
                    hint: "Reduce context size or split into multiple requests."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Rate limit or monthly quota exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                rate_limit_exceeded:
                  value:
                    error: rate_limit_exceeded
                    message: "Rate limit exceeded (10 requests per minute)."
                quota_exceeded:
                  value:
                    error: quota_exceeded
                    message: "Monthly limit of 500 requests reached. 500/500 used."
                    hint: "Check usage at GET /api/v1/usage. Contact sales to upgrade."
                    used: 500
                    limit: 500
        "503":
          description: A required downstream service is unavailable.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                sanitizer_unavailable:
                  value:
                    error: sanitizer_unavailable
                    message: "The PII sanitizer is temporarily unavailable. Requests cannot be processed safely."
                    hint: "DSA will not forward data to the AI without PII sanitization. This protects your data subjects."
                    retry_after_seconds: 30
                token_service_unavailable:
                  value:
                    error: token_service_unavailable
                    message: "The token service is temporarily unavailable."
                    retry_after_seconds: 30
                inference_service_unavailable:
                  value:
                    error: inference_service_unavailable
                    message: "The inference service is temporarily unavailable."
                    retry_after_seconds: 30

  # -------------------------------------------------------------------------
  # POST /api/v1/scan
  # -------------------------------------------------------------------------

  /api/v1/scan:
    post:
      operationId: scanText
      tags: [Public]
      summary: Public PII scan (no authentication)
      description: |
        Auth-less public surface that runs a single freetext blob through
        the L1 (known-entity) and L2 (Presidio NER) sanitizer pipeline and
        returns the redacted output plus per-redaction metadata.

        L3 (LLM PII Shield) is **not** invoked on this endpoint — that
        layer is reserved for Pro and Enterprise tiers. To exercise the
        full pipeline (bridge tokenisation, witness signature, audit
        trail), sign up at https://lucairn.eu/account/signup.

        **Limits:**
        - Maximum input size: 4096 bytes (after whitespace trim).
        - Global rate limit: 1200 requests per hour across all callers.
        - Upstream timeout: 6 seconds. On sanitizer error or shape drift
          the endpoint returns 503 with `sanitizer_unavailable`.

        **Privacy:** the endpoint is stateless. Input text and redacted
        output are never persisted; logs contain only metadata
        (latency, redaction count, hashed remote IP).
      security: []  # explicitly unauthenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  description: Freetext to scan. Required, non-empty after trim, max 4096 bytes.
                  maxLength: 4096
                  example: "Customer Anna Schmidt reached out from anna@example.com."
                language:
                  type: string
                  description: Optional language hint. Must be either "en" or "de".
                  enum: [en, de]
      responses:
        "200":
          description: Scan succeeded; redacted text returned.
          content:
            application/json:
              schema:
                type: object
                required: [redacted, redactions, latency_ms]
                properties:
                  redacted:
                    type: string
                    description: Input text with PII replaced by placeholders.
                  redactions:
                    type: array
                    items:
                      type: object
                      required: [placeholder, type]
                      properties:
                        placeholder:
                          type: string
                          example: "[PERSON_1]"
                        type:
                          type: string
                          example: "PERSON"
                        confidence:
                          type: number
                          format: float
                          example: 0.95
                        recognizer:
                          type: string
                          example: "presidio_ner"
                  latency_ms:
                    type: integer
                    description: Sanitizer call latency in milliseconds.
                  layers_active:
                    type: array
                    items:
                      type: string
                    example: [known_entity_matching, presidio_ner]
              example:
                redacted: "Customer [PERSON_1] reached out from [EMAIL_1]."
                redactions:
                  - placeholder: "[PERSON_1]"
                    type: PERSON
                    confidence: 0.95
                    recognizer: presidio_ner
                  - placeholder: "[EMAIL_1]"
                    type: EMAIL
                    confidence: 0.99
                    recognizer: presidio_ner
                latency_ms: 142
                layers_active: [known_entity_matching, presidio_ner]
        "400":
          description: Invalid input (missing/empty text, oversize, bad language).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          description: Method not allowed (only POST is accepted).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "415":
          description: Unsupported media type (Content-Type must be application/json).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Rate limited (1200 calls/hour global cap exceeded).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "503":
          description: Sanitizer temporarily unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/usage
  # -------------------------------------------------------------------------

  /api/v1/usage:
    get:
      operationId: getUsage
      tags: [Account]
      summary: Check tier and monthly quota
      description: >
        Returns the current tier, monthly request limit, usage count, and
        remaining quota for the authenticated API key.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      responses:
        "200":
          description: Usage statistics retrieved successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageResponse"
              examples:
                pro_tier:
                  value:
                    customer_id: "cust_4f3a1b2c8d9e0f1a"
                    tier: pro
                    monthly_limit: 50000
                    used: 1234
                    remaining: 48766
                    managed_ai: false
                    audit_export: true
                enterprise_tier:
                  value:
                    customer_id: "cust_9z8y7x6w5v4u"
                    tier: enterprise
                    monthly_limit: unlimited
                    used: 98765
                    remaining: unlimited
                    managed_ai: true
                    audit_export: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # -------------------------------------------------------------------------
  # GET /api/v1/audit/export
  # -------------------------------------------------------------------------

  /api/v1/audit/export:
    get:
      operationId: exportAudit
      tags: [Account]
      summary: Download audit trail
      description: |
        Returns the in-memory audit trail for the authenticated customer.
        The buffer retains the most recent **1 000 events** per customer.

        Events are ordered oldest-first. Use the `days` and `type`
        parameters to filter the result.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: days
          in: query
          description: >
            Number of past calendar days to include. Minimum 1, maximum 90.
            Defaults to 30.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
          examples:
            default:
              value: 30
            last_week:
              value: 7
        - name: type
          in: query
          description: >
            Filter by event type. When omitted all event types are returned.
            Common values: `PROXY_TOKEN_GENERATED`, `PROXY_SANITIZER_APPLIED`,
            `PROXY_INFERENCE_SUBMITTED`, `PROXY_DLP_OUTBOUND_MATCH`,
            `PROVING_GROUND_EVALUATION`.
          required: false
          schema:
            type: string
          examples:
            sanitizer:
              value: PROXY_SANITIZER_APPLIED
      responses:
        "200":
          description: Audit trail exported successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditExportResponse"
              examples:
                typical:
                  value:
                    customer_id: "cust_4f3a1b2c8d9e0f1a"
                    tier: pro
                    period: "2026-02-27 to 2026-03-28"
                    total_events: 2
                    events:
                      - timestamp: "2026-03-28T09:00:00Z"
                        event_type: PROXY_TOKEN_GENERATED
                        actor: "cust_4f3a1b2c8d9e0f1a"
                        details: '{"request_id":"req_4f3a1b2c8d9e"}'
                        request_id: "req_4f3a1b2c8d9e"
                      - timestamp: "2026-03-28T09:00:01Z"
                        event_type: PROXY_SANITIZER_APPLIED
                        actor: "cust_4f3a1b2c8d9e0f1a"
                        details: '{"request_id":"req_4f3a1b2c8d9e"}'
                        request_id: "req_4f3a1b2c8d9e"
        "400":
          description: Invalid query parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                invalid_days:
                  value:
                    error: invalid_field
                    message: "Field 'days' has an invalid value: must be a positive integer"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # -------------------------------------------------------------------------
  # GET /api/v1/account/audit
  # -------------------------------------------------------------------------

  /api/v1/account/audit:
    get:
      operationId: viewAccountAudit
      tags: [Account]
      summary: View audit-log events (org-scoped, paginated)
      description: |
        Returns the audit-log events for the authenticated user's
        organisation. Used by the website's `/account/audit` page.

        **Differences from `/api/v1/audit/export`:**
          - **Auth model:** session cookie (`dsa_session`), NOT `x-api-key`.
          - **Tier-agnostic:** available to Developer, Pro, and Enterprise
            tiers (CC-008 — viewing is a baseline feature; only
            EXPORT is Free-denied).
          - **Pagination:** small JSON pages (default `page_size=50`,
            max `200`).
          - **Payload masking:** raw audit payload bytes are NOT
            returned. Each event surfaces an allow-listed
            `payload_summary` (request_id, job_id, model, etc.) so that
            future audit-event payloads cannot accidentally leak PII
            through this endpoint.

        Scoping is by `session.OrgID` (DSA-3.3). Events for other
        organisations are never returned.

        **Rate limit:** 10 requests per minute per session.
      security:
        - SessionAuth: []
      parameters:
        - name: since
          in: query
          required: false
          description: >
            RFC3339 lower bound. Defaults to `now - 7 days`. Maximum
            window between `since` and `until` is 90 days.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: RFC3339 upper bound. Defaults to `now`.
          schema:
            type: string
            format: date-time
        - name: event_type
          in: query
          required: false
          description: Optional filter on `AuditEvent.event_type`.
          schema:
            type: string
        - name: page
          in: query
          required: false
          description: 1-indexed page number. Defaults to 1.
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page_size
          in: query
          required: false
          description: Page size. Defaults to 50, capped at 200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Audit events returned successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditViewResponse"
        "400":
          description: Invalid query parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: No session cookie or session expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                session_required:
                  value:
                    error: session_required
                    message: "A logged-in session is required for this endpoint."
        "403":
          description: Session has no organisation membership.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                no_org:
                  value:
                    error: no_org_id
                    message: "Your account has no organisation membership and no audit events to view."
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Rate limit (10/min/session) exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/veil/certificate/    (bare-prefix — reachable endpoint)
  # -------------------------------------------------------------------------
  #
  # Registered as a Go ServeMux prefix handler at
  # services/gateway/cmd/server/main.go:651-660. The default arm of the
  # dispatcher routes the bare path to VeilHandler.HandleGetCertificate
  # (services/gateway/internal/api/veil.go:68), which reaches the
  # empty-request_id branch at veil.go:83-85 and returns 400. The full
  # middleware + handler chain is reachable on the bare path in exactly
  # the order documented below.

  /api/v1/veil/certificate/:
    get:
      operationId: getVeilCertificateBarePath
      tags: [Veil]
      summary: Veil Certificate — bare prefix (missing `request_id`)
      description: |
        Reachable but always-error endpoint. The Go ServeMux prefix
        dispatcher at `services/gateway/cmd/server/main.go:651-660` routes
        the bare path `/api/v1/veil/certificate/` to
        `VeilHandler.HandleGetCertificate`
        (`services/gateway/internal/api/veil.go:68`), which extracts an
        empty `request_id` at `veil.go:83` and returns `400` at `veil.go:85`.

        This path is documented for contract completeness. Real clients
        should call `GET /api/v1/veil/certificate/{request_id}` with a
        concrete request id.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      responses:
        "400":
          description: >
            Empty `request_id` — the bare prefix `/api/v1/veil/certificate/`
            resolves to an empty id after the prefix strip.
            Produced at `services/gateway/internal/api/veil.go:84-85` via
            `ErrMissingField("request_id")`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                missing_request_id:
                  value:
                    error: missing_field
                    message: "Required field 'request_id' is missing."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            Produced in two places, both reachable on the bare path:
            (1) `services/gateway/cmd/server/main.go:543-547` —
            `licenseCheck` middleware, when `dsaLicense.ExpiresAt` is in
            the past, returns `{"error":"license expired"}`;
            (2) `services/gateway/internal/api/veil.go:400-401` —
            `authenticateAndAuthorize` returns `ErrTierInsufficient("pro", ...)`
            when `profile.Tier == "free"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                license_expired:
                  value:
                    error: license_expired
                    message: "license expired"
                tier_insufficient:
                  value:
                    error: tier_insufficient
                    message: "This endpoint requires the 'pro' tier (current tier: free)."
        "404":
          description: >
            Veil Protocol is not enabled on this instance. Produced at
            `services/gateway/internal/api/veil.go:77-78` when
            `h.veilClient == nil`. Reachable on the bare path because this
            check runs before `extractVeilRequestID` at `veil.go:83`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                veil_not_configured:
                  value:
                    error: veil_not_configured
                    message: "Veil Protocol is not enabled on this instance."
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # -------------------------------------------------------------------------
  # GET /api/v1/veil/certificate/{request_id}
  # -------------------------------------------------------------------------

  /api/v1/veil/certificate/{request_id}:
    get:
      operationId: getVeilCertificate
      tags: [Veil]
      summary: Veil Certificate — technical JSON certificate
      description: |
        Returns the full `VeilCertificate` protobuf message serialised as JSON.

        The certificate contains:
        - Ed25519-signed claims from each pipeline service
        - Five consistency checks (signatures, completeness, temporal,
          data-visibility, isolation probe)
        - Quasi-identifier risk score
        - External attestation references (RFC 3161 + Rekor transparency log)
        - V2 fields: formal verification hashes, Merkle audit integrity,
          privacy budget (ε)

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: request_id
          in: path
          required: true
          description: >
            The `request_id` returned in the proxy response (e.g.
            `req_4f3a1b2c8d9e`). Also available in the `veil.certificate_url`
            field of every Pro or Enterprise proxy response.
          schema:
            type: string
          examples:
            typical:
              value: "req_4f3a1b2c8d9e"
      responses:
        "200":
          description: VeilCertificate serialised as proto-JSON.
          content:
            application/json:
              schema:
                type: object
                description: >
                  Proto-JSON encoding of `veil.v1.VeilCertificate`. Field names
                  use proto snake_case. Top-level fields include
                  `certificate_id`, `request_id`, `issued_at`, `claims`,
                  `verification`, `attestation`, `formal_verification`,
                  `audit_integrity`, `privacy_budget`, and `witness_signature`.
              examples:
                verified:
                  value:
                    certificate_id: "cert_a1b2c3d4"
                    request_id: "req_4f3a1b2c8d9e"
                    issued_at: "2026-03-28T09:00:05Z"
                    verification:
                      overall_verdict: VERDICT_VERIFIED
                      signatures_valid: true
                      completeness: COMPLETENESS_COMPLETE
                      temporal_consistent: true
                      data_visibility_consistent: true
                      isolation_verified: true
                    witness_signature: "c3d4e5f6a7b8..."
        "400":
          description: Missing or empty `request_id` path parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                missing_request_id:
                  value:
                    error: missing_field
                    message: "Required field 'request_id' is missing."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Veil Protocol is not enabled on this instance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                veil_not_configured:
                  value:
                    error: veil_not_configured
                    message: "Veil Protocol is not enabled on this instance."
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Failed to retrieve certificate from the Witness service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                upstream_error:
                  value:
                    error: upstream_error
                    message: "Failed to retrieve certificate."

  # -------------------------------------------------------------------------
  # GET /api/v1/veil/certificate/{request_id}/summary
  # -------------------------------------------------------------------------

  /api/v1/veil/certificate/{request_id}/summary:
    get:
      operationId: getVeilCertificateSummary
      tags: [Veil]
      summary: Veil Certificate — HTML DPO summary
      description: |
        Returns an HTML page designed for Data Protection Officers (DPOs).
        The page shows the overall verdict (VERIFIED / PARTIAL / FAILED),
        all five verification checks, per-service claims (data seen / not
        seen), quasi-identifier risk, and external attestation references.

        **Minimum tier:** Free. Cross-customer requests return 404 (existence not leaked).
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      parameters:
        - name: request_id
          in: path
          required: true
          schema:
            type: string
          examples:
            typical:
              value: "req_4f3a1b2c8d9e"
      responses:
        "200":
          description: HTML DPO summary page.
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Missing or empty `request_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Veil Protocol is not enabled on this instance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Failed to retrieve certificate from the Witness service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/veil/certificate/{request_id}/regulatory
  # -------------------------------------------------------------------------

  /api/v1/veil/certificate/{request_id}/regulatory:
    get:
      operationId: getVeilCertificateRegulatory
      tags: [Veil]
      summary: Veil Certificate — regulatory mapping
      description: |
        Returns a JSON object that maps certificate fields to specific
        GDPR and EU AI Act article requirements:

        **GDPR:**
        - Art. 5(1)(c) — Data minimisation (privacy budget ε)
        - Art. 25 — Data protection by design (isolation + formal verification)
        - Art. 30 — Audit integrity (Merkle tree inclusion proof)
        - Art. 32 — Security of processing (signature + temporal integrity)
        - Art. 35 — DPIA evidence (claim completeness)

        **EU AI Act:**
        - Art. 10 — Data governance (PII sanitisation + QI risk)
        - Art. 13 — Transparency (per-service data visibility)
        - Art. 15 — Human oversight (DPO-readable summary URL)

        **Minimum tier:** Free. Cross-customer requests return 404 (existence not leaked).
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      parameters:
        - name: request_id
          in: path
          required: true
          schema:
            type: string
          examples:
            typical:
              value: "req_4f3a1b2c8d9e"
      responses:
        "200":
          description: Regulatory mapping retrieved successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  certificate_id:
                    type: string
                  request_id:
                    type: string
                  issued_at:
                    type: string
                    format: date-time
                  gdpr:
                    type: object
                    description: GDPR article mappings.
                  eu_ai_act:
                    type: object
                    description: EU AI Act article mappings.
                  external_attestations:
                    type: object
                    description: >
                      RFC 3161 and Rekor transparency log references
                      (present when external attestation is configured).
              examples:
                typical:
                  value:
                    certificate_id: "cert_a1b2c3d4"
                    request_id: "req_4f3a1b2c8d9e"
                    issued_at: "2026-03-28T09:00:05Z"
                    gdpr:
                      article_25_data_protection_by_design:
                        status: VERIFIED
                        evidence: "Split-knowledge architecture with infrastructure-level enforcement"
                        isolation: true
                      article_32_security_of_processing:
                        status: VERIFIED
                        signatures_valid: true
                        temporal_integrity: true
                    eu_ai_act:
                      article_10_data_governance:
                        status: VERIFIED
                        evidence: "PII sanitization verified, quasi-identifier risk assessed"
        "400":
          description: Missing or empty `request_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Veil Protocol is not enabled on this instance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Failed to retrieve certificate from the Witness service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/veil/verify
  # -------------------------------------------------------------------------

  /api/v1/veil/verify:
    post:
      operationId: verifyVeilCertificate
      tags: [Veil]
      summary: Independently verify a Veil Certificate
      description: |
        Verifies a VeilCertificate either by `request_id` (fetch + verify)
        or by submitting the full certificate JSON directly.

        The Veil Witness service re-runs all five consistency checks and
        returns a `VerificationResult` with pass/fail for each check.

        Use this endpoint to provide customers and auditors with an
        independent verification path that does not require trust in the
        Gateway.

        **Minimum tier:** Pro.

        **Request body options:**

        Option A — by `request_id` (Witness fetches the certificate):
        ```json
        { "request_id": "req_4f3a1b2c8d9e" }
        ```

        Option B — full certificate JSON (e.g. from a stored copy):
        ```json
        { "certificate_id": "...", "request_id": "...", ... }
        ```
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [request_id]
                  properties:
                    request_id:
                      type: string
                      description: >
                        Fetch the certificate by this ID and verify it.
                      examples:
                        - "req_4f3a1b2c8d9e"
                - type: object
                  description: >
                    Full `veil.v1.VeilCertificate` proto-JSON. Useful for
                    offline verification of a stored certificate.
            examples:
              by_request_id:
                summary: Verify by request ID
                value:
                  request_id: "req_4f3a1b2c8d9e"
              full_certificate:
                summary: Submit full certificate JSON
                value:
                  certificate_id: "cert_a1b2c3d4"
                  request_id: "req_4f3a1b2c8d9e"
                  issued_at: "2026-03-28T09:00:05Z"
      responses:
        "200":
          description: >
            Verification result as proto-JSON (`veil.v1.VerificationResult`).
          content:
            application/json:
              schema:
                type: object
                description: >
                  Proto-JSON encoding of `veil.v1.VerificationResult`.
                  Key fields: `overall_verdict`, `signatures_valid`,
                  `completeness`, `temporal_consistent`,
                  `data_visibility_consistent`, `isolation_verified`,
                  `qi_score`.
              examples:
                verified:
                  value:
                    overall_verdict: VERDICT_VERIFIED
                    signatures_valid: true
                    completeness: COMPLETENESS_COMPLETE
                    temporal_consistent: true
                    data_visibility_consistent: true
                    isolation_verified: true
        "400":
          description: Invalid JSON or unrecognised body format.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                invalid_json:
                  value:
                    error: invalid_json
                    message: "Could not parse request body as JSON."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Veil Protocol is not enabled on this instance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Verification failed at the Witness service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
              examples:
                upstream_error:
                  value:
                    error: upstream_error
                    message: "Verification failed."

  # -------------------------------------------------------------------------
  # POST /v1/messages (Anthropic Messages API-compatible)
  # -------------------------------------------------------------------------

  /v1/messages:
    post:
      operationId: anthropicMessages
      tags: [Inference]
      summary: Anthropic Messages API-compatible inference
      description: |
        Anthropic Messages API-compatible endpoint. Routes through the full
        DSA split-knowledge pipeline (Bridge tokenisation, PII sanitisation,
        Sandbox B inference) while accepting standard Anthropic request/response
        format.

        The last user message content is treated as freetext and sanitised.
        The `system` prompt is NOT sanitised (caller's responsibility), matching
        the proxy handler's `prompt_template` behaviour.

        Callers can optionally pre-classify fields via `metadata.dsa_fields`
        with `identity`, `freetext`, and `passthrough` sub-objects.

        Streaming is not supported. Set `stream` to `false` or omit it.

        **Request size limit:** 1 MB.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - model
                - max_tokens
                - messages
              properties:
                model:
                  type: string
                  description: LLM model identifier.
                  examples:
                    - "claude-3-5-sonnet-20241022"
                max_tokens:
                  type: integer
                  description: Maximum tokens in the LLM response.
                  examples:
                    - 1024
                messages:
                  type: array
                  items:
                    type: object
                    required:
                      - role
                      - content
                    properties:
                      role:
                        type: string
                        enum: [user, assistant]
                        description: Message role.
                      content:
                        description: >
                          Message content. Can be a plain string or an array of
                          content blocks with `type` and `text` fields.
                  description: Conversation turns. Must contain at least one user message.
                system:
                  type: string
                  description: >
                    System prompt. NOT sanitised — caller's responsibility to keep
                    it free of PII.
                  examples:
                    - "You are a risk analyst. Analyse the following customer data."
                temperature:
                  type: number
                  format: float
                  minimum: 0
                  maximum: 1
                  description: Sampling temperature.
                  examples:
                    - 0.7
                stream:
                  type: boolean
                  description: >
                    Must be `false` (or omitted). Streaming is not supported.
                  enum: [false]
                  default: false
                metadata:
                  type: object
                  description: >
                    DSA-specific request extensions. Optional. Same shape
                    as the `metadata` envelope on `POST /api/v1/mcp/messages`.
                  properties:
                    dsa_fields:
                      type: object
                      description: >
                        Caller-supplied field classification for the
                        split-knowledge pipeline. When absent, the
                        handler treats the last user message as freetext.
                      properties:
                        identity:
                          type: object
                          additionalProperties:
                            type: string
                          description: Fields tokenised by the Identity Bridge.
                        freetext:
                          type: object
                          additionalProperties:
                            type: string
                          description: Fields routed through Presidio + L3 LLM Shield + DLP.
                        passthrough:
                          type: object
                          additionalProperties:
                            type: string
                          description: PII-free fields forwarded as-is.
            examples:
              basic:
                summary: Basic inference
                value:
                  model: "claude-3-5-sonnet-20241022"
                  max_tokens: 1024
                  messages:
                    - role: "user"
                      content: "Analyse customer: ID 12345, Risk Score 8.2. Flag if risk > 7.0."
              with_system:
                summary: With system prompt and dsa_fields
                value:
                  model: "claude-3-5-sonnet-20241022"
                  max_tokens: 1024
                  system: "You are a risk analyst."
                  messages:
                    - role: "user"
                      content: "Customer Hans Mueller, DOB 1985-03-15, risk score 8.2."
                  metadata:
                    dsa_fields:
                      freetext:
                        _content: "Customer Hans Mueller, DOB 1985-03-15, risk score 8.2."
      responses:
        "200":
          description: Inference completed.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                  - type
                  - role
                  - content
                  - model
                  - stop_reason
                  - usage
                properties:
                  id:
                    type: string
                    description: Message ID (prefixed with `msg_dsa_`).
                    examples:
                      - "msg_dsa_req_4f3a1b2c8d9e"
                  type:
                    type: string
                    enum: [message]
                  role:
                    type: string
                    enum: [assistant]
                  content:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum: [text]
                        text:
                          type: string
                  model:
                    type: string
                    examples:
                      - "claude-3-5-sonnet-20241022"
                  stop_reason:
                    type: string
                    examples:
                      - "end_turn"
                  usage:
                    type: object
                    properties:
                      input_tokens:
                        type: integer
                      output_tokens:
                        type: integer
                  metadata:
                    type: object
                    properties:
                      dsa_compliance:
                        type: object
                        properties:
                          request_id:
                            type: string
                          veil_certificate_url:
                            type: string
                          veil_summary_url:
                            type: string
                          pii_in_ai:
                            type: boolean
                          identity_in_ai:
                            type: boolean
                          sanitizer_layers:
                            type: array
                            items:
                              type: string
                          redaction_count:
                            type: integer
                          latency_ms:
                            type: integer
              examples:
                completed:
                  value:
                    id: "msg_dsa_req_4f3a1b2c8d9e"
                    type: "message"
                    role: "assistant"
                    content:
                      - type: "text"
                        text: "Risk level HIGH — score 8.2 exceeds threshold 7.0."
                    model: "claude-3-5-sonnet-20241022"
                    stop_reason: "end_turn"
                    usage:
                      input_tokens: 142
                      output_tokens: 38
                    metadata:
                      dsa_compliance:
                        request_id: "req_4f3a1b2c8d9e"
                        veil_certificate_url: "https://your-dsa-instance/api/v1/veil/certificate/req_4f3a1b2c8d9e"
                        veil_summary_url: "https://your-dsa-instance/api/v1/veil/certificate/req_4f3a1b2c8d9e/summary"
                        pii_in_ai: false
                        identity_in_ai: false
                        sanitizer_layers: ["presidio"]
                        redaction_count: 0
                        latency_ms: 640
        "202":
          description: Inference still in progress.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [processing]
                  job_id:
                    type: string
        "400":
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "413":
          description: Request body exceeds 1MB limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Rate limit or monthly quota exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "503":
          description: A required downstream service is unavailable.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # Removed endpoints (410 Gone)
  # -------------------------------------------------------------------------

  /api/v1/inference:
    post:
      operationId: legacyInference
      tags: [Inference]
      summary: "[REMOVED] Legacy inference endpoint"
      description: |
        This endpoint has been removed. Use `POST /api/v1/proxy/messages`
        (split-knowledge format) or `POST /v1/messages` (Anthropic-compatible
        format) instead.

        Handler: `handleLegacyEndpointRemoved` at
        `services/gateway/internal/api/handler.go:307-311`.
      deprecated: true
      responses:
        "410":
          description: Endpoint removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSALegacyRemovedError"
              examples:
                gone:
                  value:
                    error: "endpoint removed — use /api/v1/proxy/messages for full split-knowledge pipeline"
                    migration: "https://lucairn.eu/docs/migration/proxy-messages"

  /api/v1/inference/:
    post:
      operationId: legacyInferenceTrailingSlash
      tags: [Inference]
      summary: "[REMOVED] Legacy inference endpoint (trailing-slash variant)"
      description: |
        Trailing-slash alias of `POST /api/v1/inference`. Registered as a
        distinct Go `ServeMux` route at `services/gateway/internal/api/handler.go:134`
        (alongside the bare path at line 133) so that clients sending a trailing
        slash do not receive an unhelpful `404`. Behavior is identical: the same
        `handleLegacyEndpointRemoved` handler at
        `services/gateway/internal/api/handler.go:307-311` returns `410 Gone`
        with the body shape defined by `DSALegacyRemovedError`. Use
        `POST /api/v1/proxy/messages` (split-knowledge format) or
        `POST /v1/messages` (Anthropic-compatible format) instead.
      deprecated: true
      responses:
        "410":
          description: Endpoint removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSALegacyRemovedError"
              examples:
                gone:
                  value:
                    error: "endpoint removed — use /api/v1/proxy/messages for full split-knowledge pipeline"
                    migration: "https://lucairn.eu/docs/migration/proxy-messages"

  /api/v1/service/analyze:
    post:
      operationId: legacyServiceAnalyze
      tags: [Inference]
      summary: "[REMOVED] Legacy service analyze endpoint"
      description: |
        This endpoint has been removed. Use `POST /api/v1/proxy/messages`
        (split-knowledge format) or `POST /v1/messages` (Anthropic-compatible
        format) instead.

        Handler: `handleLegacyEndpointRemoved` at
        `services/gateway/internal/api/handler.go:307-311`.
      deprecated: true
      responses:
        "410":
          description: Endpoint removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSALegacyRemovedError"
              examples:
                gone:
                  value:
                    error: "endpoint removed — use /api/v1/proxy/messages for full split-knowledge pipeline"
                    migration: "https://lucairn.eu/docs/migration/proxy-messages"

  # -------------------------------------------------------------------------
  # GET /.well-known/veil-keys.json
  # -------------------------------------------------------------------------

  /.well-known/veil-keys.json:
    get:
      operationId: getVeilKeys
      tags: [WellKnown]
      summary: Public key manifest (no authentication required)
      description: |
        Returns the Ed25519 public keys for every Veil-enabled service
        plus two independent signatures over the canonical body.
        Clients verify claim signatures offline against the keys here.

        Verifier recipe: strip the `signatures` object and the
        `served_at` field from the response, canonicalize the remaining
        fields under the pkg/veil canonicalizer (sorted keys, no
        whitespace) — see pkg/veil/canonical.go — then Ed25519-verify
        each of `signatures.gateway.signature` and
        `signatures.witness.signature` against those bytes. Both MUST
        validate. Either missing or invalid ⇒ reject.

        Responses carry `Cache-Control: public, max-age=300,
        must-revalidate` and a weak ETag derived from the canonical
        body. Clients should send `If-None-Match` on repeated fetches
        and honour a 304 response.

        This endpoint requires no authentication.
      responses:
        "200":
          description: Public key manifest with dual signatures.
          headers:
            Cache-Control:
              schema:
                type: string
              description: Always `public, max-age=300, must-revalidate`.
            ETag:
              schema:
                type: string
              description: >
                Weak ETag of form `W/"<first-16-hex-of-sha256(canonical-body)>"`.
                Stable across requests until the manifest is regenerated
                (i.e. until a new witness-signed blob is deployed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VeilKeysManifest"
              examples:
                typical:
                  value:
                    issuer: "DSA Veil Witness"
                    version: 1
                    supported_protocol_versions: [1, 2]
                    keys:
                      - service_id: "dsa-witness"
                        key_id: "witness_v1"
                        public_key: "a1b2c3d4e5f6..."
                        purpose: "Certificate signing"
                        algorithm: Ed25519
                        key_state: active
                      - service_id: "dsa-bridge"
                        key_id: "bridge_v1"
                        public_key: "b2c3d4e5f6a7..."
                        purpose: "Bridge claim signing"
                        algorithm: Ed25519
                        key_state: active
                    signed_at: "2026-04-24T12:00:00Z"
                    signatures:
                      gateway:
                        key_id: "gateway_manifest_v1"
                        algorithm: Ed25519
                        signature: "c3d4e5f6a7b8..."
                      witness:
                        key_id: "witness_manifest_v1"
                        algorithm: Ed25519
                        signature: "d4e5f6a7b8c9..."
                    served_at: "2026-04-24T14:35:07Z"
        "304":
          description: >
            Not modified. Returned when the request's `If-None-Match`
            header matches the current ETag. Empty body; the response
            still carries `Cache-Control` and `ETag` headers so clients
            may continue to revalidate.
          headers:
            Cache-Control:
              schema:
                type: string
            ETag:
              schema:
                type: string
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # =========================================================================
  # Session — portal browser login
  # =========================================================================

  # -------------------------------------------------------------------------
  # POST /api/v1/auth/login
  # -------------------------------------------------------------------------

  /api/v1/auth/login:
    post:
      operationId: login
      tags: [Session]
      summary: Authenticate and create portal session cookie
      description: |
        Validates email/password against Sandbox A's identity service
        (per vertical) and, on success, sets a HTTPOnly SameSite=Strict
        `dsa_session` cookie with a 1-hour TTL. Emits a `GATEWAY_LOGIN`
        audit event.

        Used by the portal UI only; programmatic clients should use
        `ApiKeyAuth` instead.

        **Request size limit:** 1 MB.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, vertical]
              properties:
                email:
                  type: string
                  format: email
                  examples:
                    - alice@example.com
                password:
                  type: string
                  format: password
                  writeOnly: true
                vertical:
                  type: string
                  description: Identity namespace to authenticate against.
                  examples:
                    - default
      responses:
        "200":
          description: Authenticated — cookie set.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: >
                `dsa_session=...; HttpOnly; Secure; SameSite=Strict; Path=/;
                Max-Age=3600`
          content:
            application/json:
              schema:
                type: object
                properties:
                  display_name:
                    type: string
                  email:
                    type: string
                    format: email
        "400":
          description: Invalid JSON or missing field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Credentials rejected by Sandbox A.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "403":
          description: License expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # -------------------------------------------------------------------------
  # DELETE /api/v1/auth/session
  # -------------------------------------------------------------------------

  /api/v1/auth/session:
    delete:
      operationId: logout
      tags: [Session]
      summary: Revoke current session cookie
      description: |
        Invalidates the `dsa_session` cookie server-side and clears it on
        the client. Emits a `GATEWAY_LOGOUT` audit event.
      security:
        - SessionAuth: []
      responses:
        "204":
          description: Session revoked.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: Cookie cleared (`Max-Age=0`).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # =========================================================================
  # Inference — MCP and OpenAI compatibility endpoints
  # =========================================================================

  # -------------------------------------------------------------------------
  # POST /v1/chat/completions (OpenAI Chat Completions-compatible)
  # -------------------------------------------------------------------------

  /v1/chat/completions:
    post:
      operationId: openaiChatCompletions
      tags: [OpenAI, Inference]
      summary: OpenAI Chat Completions-compatible inference
      description: |
        Adapts the OpenAI Chat Completions request/response format to DSA's
        full split-knowledge pipeline (Bridge tokenisation → Sanitizer →
        Sandbox B inference). Allows stock OpenAI SDKs to work unchanged
        by setting `base_url` to the DSA instance.

        Last `user` message content is treated as freetext and sanitised;
        `system` message content is PII-firewalled (hard-identifier reject,
        matching the proxy handler).

        Streaming is not currently supported — `stream: true` is rejected
        with `400 invalid_request_error`.

        **Request size limit:** 1 MB.
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, messages]
              properties:
                model:
                  type: string
                messages:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role:
                        type: string
                        enum: [user, assistant, system]
                      content:
                        type: string
                temperature:
                  type: number
                  format: float
                max_tokens:
                  type: integer
                stream:
                  type: boolean
                  default: false
      responses:
        "200":
          description: Completion returned.
          content:
            application/json:
              schema:
                type: object
                required: [id, object, created, model, choices, usage]
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    enum: [chat.completion]
                  created:
                    type: integer
                    description: Unix seconds.
                  model:
                    type: string
                  choices:
                    type: array
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                        message:
                          type: object
                          properties:
                            role:
                              type: string
                              enum: [assistant]
                            content:
                              type: string
                        finish_reason:
                          type: string
                          enum: [stop, length, error]
                  usage:
                    type: object
                    properties:
                      prompt_tokens:
                        type: integer
                      completion_tokens:
                        type: integer
                      total_tokens:
                        type: integer
        "202":
          description: Async inference accepted — poll via `/api/v1/proxy/jobs/{job_id}`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [processing]
                  job_id:
                    type: string
        "400":
          description: "Invalid request (includes `stream: true`, PII in system prompt, bad JSON)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "413":
          description: Request body exceeds 1 MB limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Rate limit or monthly quota exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "499":
          description: >
            Client closed connection during streaming. Written as a best-effort
            status by the streaming handler when the writer detects the client
            has gone away. Not a standard HTTP status but emitted by `WriteHeader(499)`.
        "500":
          description: Upstream inference pipeline failed after the request was accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "503":
          description: A required downstream service is unavailable.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/mcp/messages (MCP-compatible Messages endpoint)
  # -------------------------------------------------------------------------

  /api/v1/mcp/messages:
    post:
      operationId: mcpMessages
      tags: [MCP, Inference]
      summary: MCP-compatible messages endpoint with per-key system-prompt policy
      description: |
        Anthropic Messages API variant for MCP clients (Claude Code,
        Claude Desktop). Identical to `POST /v1/messages` in request
        shape, but **differs in system-prompt handling**:

        - `mcp_system_policy = sanitize` (default): Presidio/QI redacts
          PII inside system blocks before forwarding.
        - `mcp_system_policy = passthrough_audit`: PII findings are
          logged to `MCP_SYSTEM_POLICY_APPLIED` but the original system
          prompt is passed through unchanged.

        Policy is per-API-key (`PATCH /api/v1/admin/keys/{key_id}/mcp-policy`).

        Streaming is available when the gateway is started with
        `STREAMING_ENABLED=true`.

        **Request size limit:** 1 MB.
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, max_tokens, messages]
              # NOTE: this schema mirrors MCPPayloadSchema in
              # services/gateway/internal/api/mcp_payload_schema.go.
              # If you change the field set, update that file (and bump
              # MCPPayloadSchemaVersion) in the same change. The
              # TestMCPPayloadSchemaMatchesLiveRequest drift test will
              # fail at CI time otherwise.
              properties:
                model:
                  type: string
                  description: LLM model identifier (e.g. `claude-sonnet-4-6`).
                max_tokens:
                  type: integer
                  minimum: 1
                  description: Max output tokens. Capped further by per-tier `MaxOutputTokens`.
                messages:
                  type: array
                  minItems: 1
                  description: Conversation turns. Must contain at least one user message.
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role:
                        type: string
                        enum: [user, assistant, system]
                      content:
                        description: String or array of content blocks.
                system:
                  description: >
                    System prompt — string OR array of content blocks.
                    Subjected to the per-key `mcp_system_policy`
                    (sanitize | passthrough_audit) before forwarding.
                stream:
                  type: boolean
                  default: false
                  description: SSE streaming. Requires `STREAMING_ENABLED=true` on the gateway.
                temperature:
                  type: number
                  format: float
                  description: Sampling temperature (provider default when omitted).
                metadata:
                  type: object
                  description: >
                    DSA-specific request extensions. Optional. Same shape
                    as the `metadata` envelope on `POST /v1/messages`.
                  properties:
                    dsa_fields:
                      type: object
                      description: >
                        Caller-supplied field classification for the
                        split-knowledge pipeline. When absent, the
                        handler treats the last user message as freetext.
                      properties:
                        identity:
                          type: object
                          additionalProperties:
                            type: string
                          description: Fields tokenised by the Identity Bridge before any data reaches the inference path.
                        freetext:
                          type: object
                          additionalProperties:
                            type: string
                          description: Fields routed through Presidio + L3 LLM Shield + DLP. The reserved key `_content` carries the user message body.
                        passthrough:
                          type: object
                          additionalProperties:
                            type: string
                          description: PII-free fields forwarded as-is (e.g. opaque correlation IDs).
            examples:
              basic:
                summary: Basic MCP inference
                value:
                  model: "claude-sonnet-4-6"
                  max_tokens: 1024
                  messages:
                    - role: "user"
                      content: "Summarise the latest support ticket."
              with_system_and_dsa_fields:
                summary: With system prompt and explicit dsa_fields classification
                value:
                  model: "claude-sonnet-4-6"
                  max_tokens: 1024
                  system: "You are a triage assistant."
                  messages:
                    - role: "user"
                      content: "Customer reported a failed login at 09:42 CEST."
                  metadata:
                    dsa_fields:
                      freetext:
                        _content: "Reported failed login at 09:42 CEST."
                      passthrough:
                        request_id: "req-9f2c"
      responses:
        "200":
          description: Inference completed.
          content:
            application/json:
              schema:
                type: object
                description: >
                  Anthropic-compatible Messages response with extended
                  `metadata.dsa_compliance` block. In addition to the
                  standard `dsa_compliance` shape (see `POST /v1/messages`),
                  the MCP path adds `mcp_system_policy`,
                  `system_pii_detected`, `system_entity_count`, and
                  `system_passthrough_warning`.
                properties:
                  id:
                    type: string
                    description: Message ID, prefixed with `msg_dsa_`.
                  type:
                    type: string
                    enum: [message]
                  role:
                    type: string
                    enum: [assistant]
                  content:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum: [text]
                        text:
                          type: string
                  model:
                    type: string
                  stop_reason:
                    type: string
                  usage:
                    type: object
                    properties:
                      input_tokens:
                        type: integer
                      output_tokens:
                        type: integer
                  metadata:
                    type: object
                    properties:
                      dsa_compliance:
                        type: object
                        properties:
                          request_id:
                            type: string
                          veil_certificate_url:
                            type: string
                          veil_summary_url:
                            type: string
                          pii_in_ai:
                            type: boolean
                          identity_in_ai:
                            type: boolean
                          sanitizer_layers:
                            type: array
                            items:
                              type: string
                          redaction_count:
                            type: integer
                          latency_ms:
                            type: integer
                          mcp_system_policy:
                            type: string
                            enum: [sanitize, passthrough_audit]
                            description: The policy the gateway applied to the system block on this request.
                          system_pii_detected:
                            type: boolean
                          system_entity_count:
                            type: integer
                          system_passthrough_warning:
                            type: boolean
                            description: Set when policy is `passthrough_audit` AND PII was found in the system prompt.
        "202":
          description: Async inference accepted — poll via `/api/v1/proxy/jobs/{job_id}`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [processing]
                  job_id:
                    type: string
        "400":
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "413":
          description: Request body exceeds 1 MB limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Rate limit or monthly quota exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "499":
          description: >
            Client closed connection during streaming. Written as a best-effort
            status when the writer detects the client has gone away.
        "500":
          description: Upstream inference pipeline failed after the request was accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "503":
          description: A required downstream service is unavailable.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/proxy/jobs/{job_id}
  # -------------------------------------------------------------------------

  /api/v1/proxy/jobs/{job_id}:
    get:
      operationId: getProxyJob
      tags: [Inference]
      summary: Poll an async proxy job
      description: |
        Returns the status and (if complete) the result of an async
        `POST /api/v1/proxy/messages` request. Jobs are kept in-memory
        for 30 minutes; a restart or expiry returns 404.

        While processing, the response is HTTP 202 with a minimal body.
        On completion, HTTP 200 with the full proxy response shape.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: free
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
          examples:
            typical:
              value: "job_9b8c7d6e5f4a"
      responses:
        "200":
          description: Job completed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProxyResponse"
        "202":
          description: Job still processing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [processing]
                  job_id:
                    type: string
                  request_id:
                    type: string
                  status_url:
                    type: string
        "400":
          description: Missing or invalid `job_id` path segment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Job not found (expired or gateway restarted).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "503":
          description: Inference service unavailable (async job queue offline).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/audit/evidence/{evidence_id}
  # -------------------------------------------------------------------------

  /api/v1/audit/evidence/{evidence_id}:
    get:
      operationId: getAuditEvidence
      tags: [Account]
      summary: Audit-evidence bundle for a single `request_id`
      description: |
        Filters the audit trail to the events associated with a single
        inference `request_id` and returns a signed-artifact-style
        evidence bundle for compliance packs.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: evidence_id
          in: path
          required: true
          description: The inference `request_id` (same identifier used by the Veil endpoints).
          schema:
            type: string
          examples:
            typical:
              value: "req_4f3a1b2c8d9e"
        - name: days
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
        - name: type
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Evidence bundle.
          content:
            application/json:
              schema:
                type: object
                properties:
                  artifact_type:
                    type: string
                    enum: [gateway_audit_evidence]
                  generated_at:
                    type: string
                    format: date-time
                  request_id:
                    type: string
                  customer_id:
                    type: string
                  tier:
                    type: string
                  period:
                    type: string
                  source:
                    type: string
                  matched_event_count:
                    type: integer
                  event_types:
                    type: array
                    items:
                      type: string
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuditEntry"
        "400":
          description: Missing or invalid `evidence_id` / query parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No audit events found for this `request_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "503":
          description: Audit service unavailable.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # =========================================================================
  # Compliance — GDPR / EU AI Act endpoints (Solo Pro / Enterprise)
  # All endpoints below carry x-dsa-min-tier: pro; Solo Free rejected at auth.
  # =========================================================================

  # -------------------------------------------------------------------------
  # GET /api/v1/compliance/posture
  # -------------------------------------------------------------------------

  /api/v1/compliance/posture:
    get:
      operationId: getCompliancePosture
      tags: [Compliance]
      summary: Current compliance posture score
      description: |
        Aggregates Veil certificates + audit trail (last 30 days) into a
        compliance-posture snapshot. Optional `activity_id` filters to
        a specific processing activity.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: activity_id
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Posture snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  posture:
                    type: object
                    properties:
                      score:
                        type: number
                        format: float
                        minimum: 0
                        maximum: 100
                      status:
                        type: string
                        enum: [compliant, at_risk, non_compliant]
                      categories:
                        type: object
                  profile:
                    type: object
                  note:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Upstream compliance-aggregator error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/compliance/posture/history
  # -------------------------------------------------------------------------

  /api/v1/compliance/posture/history:
    get:
      operationId: getCompliancePostureHistory
      tags: [Compliance]
      summary: Historical compliance posture snapshots
      description: |
        Returns the list of persisted posture snapshots for this customer.
        Current implementation returns a single live snapshot until the
        `compliance_snapshots` persistence layer ships.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      responses:
        "200":
          description: Posture history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  history:
                    type: array
                    items:
                      type: object
                      properties:
                        posture:
                          type: object
                        computed_at:
                          type: string
                          format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Upstream compliance-aggregator error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/compliance/activities
  # -------------------------------------------------------------------------

  /api/v1/compliance/activities:
    get:
      operationId: listComplianceActivities
      tags: [Compliance]
      summary: List processing activities
      description: |
        Activities are created out-of-band via the `dsa-prove` CLI. This
        endpoint returns an empty list until the activities persistence
        layer ships.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      responses:
        "200":
          description: Activities list (currently stub).
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  activities:
                    type: array
                    items:
                      type: object
                  note:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

  # -------------------------------------------------------------------------
  # GET /api/v1/compliance/dpia/{activity_id}
  # -------------------------------------------------------------------------

  /api/v1/compliance/dpia/{activity_id}:
    get:
      operationId: getComplianceDPIA
      tags: [Compliance]
      summary: Generate DPIA for a processing activity
      description: |
        Generates a Data Protection Impact Assessment (GDPR Art. 35)
        template for a specific processing activity. Returns a stub
        document if the compliance aggregator is not configured.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: activity_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: DPIA document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity:
                    type: object
                  risk_assessment:
                    type: object
                  note:
                    type: string
        "400":
          description: Missing or empty `activity_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Upstream compliance-aggregator error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/compliance/evidence-pack
  # -------------------------------------------------------------------------

  /api/v1/compliance/evidence-pack:
    post:
      operationId: generateComplianceEvidencePack
      tags: [Compliance]
      summary: Generate a compliance evidence pack
      description: |
        Assembles Veil certificates + audit trail + posture score for the
        last 90 days into a single evidence bundle intended for regulators.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Evidence pack generated synchronously (stub path when aggregator is not configured).
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  generated_at:
                    type: string
                    format: date-time
                  status:
                    type: string
                    enum: [aggregator_not_configured]
        "202":
          description: Evidence pack generation accepted (async).
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  generated_at:
                    type: string
                    format: date-time
                  profile:
                    type: object
                  posture:
                    type: object
                  status:
                    type: string
                    enum: [generated, aggregator_not_configured]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Upstream compliance-aggregator error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/compliance/dsar/{identity_id}
  # -------------------------------------------------------------------------

  /api/v1/compliance/dsar/{identity_id}:
    post:
      operationId: fulfilComplianceDSAR
      tags: [Compliance]
      summary: Orchestrate a GDPR Data Subject Access Request
      description: |
        Fulfills GDPR Art. 15 (right of access) requests by combining:

        - identity record (Sandbox A)
        - all pseudonymous tokens for the subject (Bridge)
        - audit trail for those tokens (Audit Service)
        - Veil certificates touching those tokens (Veil Service)

        Non-enterprise tiers receive a redacted identity (field names/types
        only — raw values are withheld).

        Requires `DSAR_ENABLED=true`; otherwise the endpoint returns 200
        with `status: disabled`.

        Emits `DSAR_FULFILLED` audit event.

        **Minimum tier:** Pro.
      security:
        - ApiKeyAuth: []
      x-dsa-min-tier: pro
      parameters:
        - name: identity_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: DSAR fulfilled (or disabled).
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id:
                    type: string
                  customer_id:
                    type: string
                  identity_id:
                    type: string
                  identity:
                    type: object
                  tokens:
                    type: array
                    items:
                      type: object
                  audit_trail:
                    type: array
                    items:
                      type: object
                  veil_certificates:
                    type: object
                  generated_at:
                    type: string
                    format: date-time
                  status:
                    type: string
                    enum: [fulfilled, disabled]
        "400":
          description: Missing or empty `identity_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "502":
          description: Upstream compliance-aggregator error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "503":
          description: DSAR clients not configured on this deployment (Identity/Bridge/Audit client missing).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # =========================================================================
  # Admin — operator-only endpoints (X-Admin-Key required)
  # =========================================================================

  # -------------------------------------------------------------------------
  # POST /api/v1/admin/keys
  # -------------------------------------------------------------------------

  /api/v1/admin/keys:
    x-internal: true
    post:
      operationId: adminCreateKey
      tags: [Admin]
      summary: Create an API key for a customer
      description: |
        Generates a `dsa_`-prefixed API key with a random 16-byte hex
        suffix. Validates the tier/managed_ai invariant (Managed AI is
        rejected on the Pro tier). Emits `ADMIN_KEY_CREATED`.

        The full key is returned exactly once — never again.
      security:
        - AdminKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id]
              properties:
                customer_id:
                  type: string
                vertical:
                  type: string
                  default: default
                label:
                  type: string
                consultant_email:
                  type: string
                  format: email
                anthropic_key:
                  type: string
                  deprecated: true
                  description: Use `provider_key` instead.
                provider:
                  type: string
                  default: anthropic
                provider_key:
                  type: string
                rate_limit_rpm:
                  type: integer
                  default: 60
                tier:
                  type: string
                  enum: [free, pro, enterprise]
                  default: free
                llm_provider:
                  type: string
                managed_ai:
                  type: boolean
                byok_per_request:
                  type: boolean
      responses:
        "201":
          description: Key created (current handler response).
          content:
            application/json:
              schema:
                type: object
                required: [dsa_api_key]
                properties:
                  dsa_api_key:
                    type: string
                    examples:
                      - "dsa_4f3a1b2c8d9e0f1a2b3c4d5e6f7a8b9c"
        "400":
          description: Invalid JSON, missing `customer_id`, or invariant violation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "500":
          description: Failed to generate the random key material.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # DELETE /api/v1/admin/keys/{prefix}
  # -------------------------------------------------------------------------

  /api/v1/admin/keys/{prefix}:
    x-internal: true
    delete:
      operationId: adminRevokeKey
      tags: [Admin]
      summary: Revoke API key(s) by prefix
      description: |
        Revokes every API key whose prefix matches the supplied value
        (typically the first 12 chars, e.g. `dsa_abc123def`). Prefix
        matching lets the operator revoke keys without storing the full
        secret anywhere.
      security:
        - AdminKeyAuth: []
      parameters:
        - name: prefix
          in: path
          required: true
          schema:
            type: string
          examples:
            typical:
              value: "dsa_abc123de"
      responses:
        "200":
          description: Key(s) revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [revoked]
                  prefix:
                    type: string
        "400":
          description: Missing or empty `prefix`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "404":
          description: No keys match the supplied prefix.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/admin/customers
  # -------------------------------------------------------------------------

  /api/v1/admin/customers:
    x-internal: true
    get:
      operationId: adminListCustomers
      tags: [Admin]
      summary: List all customers
      description: |
        Returns a summary of every registered API key with the full key
        masked to the first 12 chars. Sorted by creation order. Non-GET
        methods return 405 per the handler at
        `services/gateway/internal/api/admin.go:390`.
      security:
        - AdminKeyAuth: []
      responses:
        "200":
          description: Customer summaries.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    api_key_prefix:
                      type: string
                      description: First 12 chars of the DSA key.
                    customer_id:
                      type: string
                    tier:
                      type: string
                    vertical:
                      type: string
                    managed_ai:
                      type: boolean
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/admin/customers/delete
  # -------------------------------------------------------------------------

  /api/v1/admin/customers/delete:
    x-internal: true
    post:
      operationId: adminDeleteCustomer
      tags: [Admin]
      summary: Delete every API key for a customer
      description: |
        Removes every API key belonging to a `customer_id`. Cascades to
        sessions. Returns the count of deleted keys.
      security:
        - AdminKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id]
              properties:
                customer_id:
                  type: string
      responses:
        "200":
          description: Customer deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    enum: [true]
                  customer_id:
                    type: string
                  keys_removed:
                    type: integer
        "400":
          description: Invalid JSON or missing field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # GET /api/v1/admin/customers/{customer_id}/keys (#15)
  # POST /api/v1/admin/customers/{customer_id}/keys (#15)
  # DELETE /api/v1/admin/customers/{customer_id}/keys/{key_id} (#15)
  # PATCH /api/v1/admin/customers/{customer_id} (#16)
  #
  # Rate-limit dimensions shared across the four endpoints below
  # (referenced from the RateLimited 429 component):
  #
  #   - per source IP, 60 requests/minute combined across all four
  #     endpoints (one shared bucket per remote IP);
  #   - per (customer_id, "reveal"), 5 requests/minute additional
  #     bucket — only consumed by GET /keys?reveal=true.
  #
  # Either dimension exhausting yields 429 with `Retry-After`.
  # -------------------------------------------------------------------------

  /api/v1/admin/customers/{customer_id}/keys:
    x-internal: true
    parameters:
      - name: customer_id
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: adminListCustomerKeys
      tags: [Admin]
      summary: List API keys for a customer
      description: |
        Returns one entry per active API key registered for `customer_id`.
        Used by the website's self-service key-management surface (#15).

        Default behaviour returns metadata only — `raw_key` is empty for
        every entry. Setting `?reveal=true` decrypts and returns the
        full raw key (the keystore stores `EncryptedRawKey` per record
        as part of PR #72). The reveal path:

        - requires the keystore to have an encryption key configured;
        - consumes one token from the per-`(customer_id, "reveal")`
          rate-limit bucket (5 requests / minute);
        - emits one `ADMIN_CUSTOMER_KEY_REVEAL` audit event regardless
          of how many keys end up in the response.

        Control-API-synced entries (written via `SetByHash`) have no
        recoverable raw key — those entries surface with
        `key_prefix="***"` and `raw_key=""` even on `?reveal=true`.

        `max_keys` reflects the per-tier cap (Free=1, Pro/Enterprise=5)
        used by the mint endpoint to enforce the multi-key budget.
      security:
        - AdminKeyAuth: []
      parameters:
        - name: reveal
          in: query
          required: false
          description: |
            When `true`, decrypts and returns each entry's full raw key.
            Audit-emitted; rate-limited to 5/min per customer.
          schema:
            type: string
            enum: ["true"]
      responses:
        "200":
          description: List of keys for the customer.
          content:
            application/json:
              schema:
                type: object
                required: [customer_id, tier, byok_per_request, provider, has_provider_key, max_keys, keys]
                properties:
                  customer_id:
                    type: string
                  tier:
                    type: string
                    enum: [free, pro, enterprise]
                  byok_per_request:
                    type: boolean
                  provider:
                    type: string
                  has_provider_key:
                    type: boolean
                    description: |
                      `true` when a stored `provider_key` exists for the
                      customer. The actual value is never returned by
                      this endpoint.
                  max_keys:
                    type: integer
                    description: Per-tier cap. Free=1, Pro/Enterprise=5.
                  keys:
                    type: array
                    items:
                      type: object
                      required: [key_prefix]
                      properties:
                        key_id:
                          type: string
                          pattern: "^k_[a-z2-7]{16}$"
                          description: |
                            Globally unique opaque revoke handle for
                            this key — `"k_"` + 16
                            base32-lowercase-no-padding chars (RFC
                            4648 §6 alphabet, 80 bits of entropy
                            from crypto/rand). Stable for the
                            lifetime of the key.

                            ABSENT for control-API-synced entries
                            (those have no self-serve revoke path).
                            Otherwise present and unique across the
                            entire fleet — the website passes this
                            value to `DELETE
                            /api/v1/admin/customers/{customer_id}/keys/{key_id}`.
                        key_prefix:
                          type: string
                          description: |
                            8-char operator-display prefix
                            (`KeyPrefix` from PR #72). Set to `"***"`
                            for control-API-synced entries with no
                            recoverable raw key. NOT a unique handle
                            — two keys belonging to one customer can
                            collide on this prefix (16⁴ entropy after
                            "dsa_"). Use `key_id` for revoke; this
                            field is for human-readable display only.
                        raw_key:
                          type: string
                          description: |
                            ABSENT from the response unless
                            `?reveal=true` was supplied AND the
                            keystore has encryption configured AND
                            the entry is not a control-API-sync row.
                            Treat absence as equivalent to empty for
                            UI purposes.
                        created_at:
                          type: string
                          format: date-time
                        last_used_at:
                          type: string
                          format: date-time
                          description: |
                            Best-effort, eventually consistent. Updated
                            asynchronously after every successful
                            authenticated proxy call.
        "401":
          $ref: "#/components/responses/AdminUnauthorized"
        "404":
          description: No API keys registered for this `customer_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: adminMintCustomerKey
      tags: [Admin]
      summary: Mint a new API key for a customer
      description: |
        Mints a new `dsa_`-prefixed API key under an existing customer
        record. Behaviour depends on tier:

        - **Free**: locked at one active key. POST atomically revokes
          the existing key and mints a replacement (regenerate
          semantic). Emits `ADMIN_CUSTOMER_KEY_REGENERATE` followed by
          `ADMIN_CUSTOMER_KEY_MINT`.
        - **Pro / Enterprise**: adds a new key up to the per-tier cap
          (5). When the cap is reached, returns `409` with
          `error="max_keys_reached"`.

        The new key inherits tier, BYOK config, provider, MCP policy
        and `org_id` from the existing customer record. Every new key
        gets its own `KeyPrefix`, `EncryptedRawKey`, and `CreatedAt`
        via the keystore's `Set` path.

        Returns the raw key in the response — the only opportunity to
        surface it through the API. Subsequent reads use the
        `reveal=true` query on the GET endpoint (#15).
      security:
        - AdminKeyAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: |
                Body is currently ignored — present for future
                customisation (e.g. label per key).
      responses:
        "201":
          description: Key minted.
          content:
            application/json:
              schema:
                type: object
                required: [key_id, key_prefix, raw_key, created_at]
                properties:
                  key_id:
                    type: string
                    pattern: "^k_[a-z2-7]{16}$"
                    description: |
                      Canonical revoke handle for the new key. The
                      website stores this and passes it back to
                      `DELETE /api/v1/admin/customers/{customer_id}/keys/{key_id}`.
                  key_prefix:
                    type: string
                  raw_key:
                    type: string
                    description: The full `dsa_…` key. Returned ONCE.
                  created_at:
                    type: string
                    format: date-time
        "400":
          description: Invariant violation (e.g. retired tier on existing record).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/AdminUnauthorized"
        "404":
          description: No existing record for `customer_id`. Mint requires an existing customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "409":
          description: |
            Tier cap reached. Pro/Enterprise customers must revoke an
            existing key before minting another. Free customers should
            never see this — Free always regenerates.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DSAError"
                  - type: object
                    properties:
                      error:
                        enum: [max_keys_reached]
                      max_keys:
                        type: integer
                        description: |
                          Tier-specific cap (Pro/Enterprise = 5).
                          Surfaced so the website can render
                          "N of M keys" without re-deriving the cap
                          from the tier string.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/admin/customers/{customer_id}/keys/{key_id}:
    x-internal: true
    parameters:
      - name: customer_id
        in: path
        required: true
        schema:
          type: string
      - name: key_id
        in: path
        required: true
        description: |
          Globally unique opaque revoke handle returned by the list
          endpoint (`"k_"` + 16 base32-lowercase chars). Stable for
          the lifetime of the key.
        schema:
          type: string
          pattern: "^k_[a-z2-7]{16}$"
    delete:
      operationId: adminRevokeCustomerKey
      tags: [Admin]
      summary: Revoke one API key for a customer
      description: |
        Revokes a single API key matched by `key_id`. KeyID is
        globally unique (80 bits of entropy from crypto/rand) so a
        match can never be ambiguous between two customers. The
        endpoint nonetheless verifies that the matched key belongs
        to the URL-named `customer_id` before deleting — defense in
        depth on the audit attribution.

        Pre-#15 H1 fix: this endpoint accepted an 8-char `KeyPrefix`
        path parameter. KeyPrefix can collide across two keys
        belonging to one customer (16⁴ entropy after `dsa_`), so a
        revoke could land on the wrong key with audit attribution
        to the right customer. KeyID closes that vector.

        Emits `ADMIN_CUSTOMER_KEY_REVOKE` with `customer_id`,
        `key_id`, and `key_prefix` (captured from the list snapshot
        before deletion). Idempotent: a re-revoke of an
        already-deleted key returns 404, not 200.
      security:
        - AdminKeyAuth: []
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema:
                type: object
                required: [revoked, key_id, key_prefix]
                properties:
                  revoked:
                    type: boolean
                    enum: [true]
                  key_id:
                    type: string
                  key_prefix:
                    type: string
        "401":
          $ref: "#/components/responses/AdminUnauthorized"
        "404":
          description: |
            No key with the supplied `key_id` belongs to this
            `customer_id`. Returned for unknown KeyIDs AND for
            valid KeyIDs that belong to a different tenant
            (cross-tenant defense).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/admin/customers/{customer_id}:
    x-internal: true
    parameters:
      - name: customer_id
        in: path
        required: true
        schema:
          type: string
    patch:
      operationId: adminUpdateCustomerBYOK
      tags: [Admin]
      summary: Update BYOK configuration for a customer
      description: |
        Updates `byok_per_request`, `provider`, and/or `provider_key`
        atomically across every key the customer holds. Used by the
        BYOK upstream-key configuration surface (#16).

        Pointer-style request body: an omitted field preserves the
        current value; an explicit field (including `false` /
        empty-string) sets it. Same convention as the control-plane
        `syncCustomer.byok_per_request` field.

        Invariants enforced:

        - Free tier is locked at `byok_per_request=true`. Explicit
          `byok_per_request=false` returns `400`.
        - `byok_per_request=true` with a non-empty `provider_key`
          returns `400` (mutually exclusive). When only
          `byok_per_request=true` is supplied, any stored
          `provider_key` is cleared atomically.
        - `provider` must be one of: `anthropic`, `openai`, `mistral`,
          `gemini`, `ollama`.

        Audit payload (`ADMIN_CUSTOMER_BYOK_CHANGE`) records only
        whether `provider_key` was `set` or `cleared`. The actual
        secret value is never written to the audit trail or logs.
      security:
        - AdminKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                byok_per_request:
                  type: boolean
                  description: Free tier is locked at `true`.
                provider:
                  type: string
                  enum: [anthropic, openai, mistral, gemini, ollama]
                provider_key:
                  type: string
                  description: |
                    Empty string clears the stored key. Mutually
                    exclusive with `byok_per_request=true`.
      responses:
        "200":
          description: BYOK config updated across all customer keys.
          content:
            application/json:
              schema:
                type: object
                required: [updated, byok_per_request, provider, has_provider_key]
                properties:
                  updated:
                    type: boolean
                    enum: [true]
                  byok_per_request:
                    type: boolean
                  provider:
                    type: string
                  has_provider_key:
                    type: boolean
        "400":
          description: |
            Invalid JSON, missing all updatable fields, unknown
            provider, or invariant violation. Specific 400 cases:

            - free tier opt-out: explicit `byok_per_request=false`
              against a Free customer (Free is locked at BYOK).
            - mutual exclusivity: `byok_per_request=true` supplied
              alongside a non-empty `provider_key`.
            - provider-change without key transition: `provider`
              is supplied alone (no `provider_key`, no
              `byok_per_request=true`) against a customer that
              already has a stored `provider_key` for a different
              provider. Force the caller to be explicit about the
              key — supply `provider_key=""` to clear, or supply
              the new provider's key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          $ref: "#/components/responses/AdminUnauthorized"
        "404":
          description: No API keys registered for this `customer_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # GET /api/v1/admin/sessions
  # -------------------------------------------------------------------------

  /api/v1/admin/sessions:
    x-internal: true
    get:
      operationId: adminListSessions
      tags: [Admin]
      summary: List active portal sessions
      description: |
        Returns every active `dsa_session`. Optional `org_id` query param
        filters to a single org. Only session metadata is returned —
        never encrypted body.
      security:
        - AdminKeyAuth: []
      parameters:
        - name: org_id
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Session summaries.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    session_id:
                      type: string
                    identity_id:
                      type: string
                    display_name:
                      type: string
                    email:
                      type: string
                      format: email
                    vertical:
                      type: string
                    org_id:
                      type: string
                    created_at:
                      type: string
                      format: date-time
                    expires_at:
                      type: string
                      format: date-time
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # DELETE /api/v1/admin/sessions/{sessionID}
  # -------------------------------------------------------------------------

  /api/v1/admin/sessions/{sessionID}:
    x-internal: true
    delete:
      operationId: adminRevokeSession
      tags: [Admin]
      summary: Revoke an active session
      description: Immediately invalidates a single session. The user loses access on the next request.
      security:
        - AdminKeyAuth: []
      parameters:
        - name: sessionID
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Session revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [revoked]
                  session_id:
                    type: string
        "400":
          description: Missing or empty `sessionID`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # PATCH /api/v1/admin/keys/tier
  # -------------------------------------------------------------------------

  /api/v1/admin/keys/tier:
    x-internal: true
    patch:
      operationId: adminUpdateTier
      tags: [Admin]
      summary: Change tier for every key under a customer
      description: |
        Updates the tier across every API key owned by `customer_id`.
        Validates the tier/managed_ai invariant (rejects Managed AI on
        Pro). Emits `ADMIN_TIER_CHANGE` with old + new tier.
      security:
        - AdminKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, tier]
              properties:
                customer_id:
                  type: string
                tier:
                  type: string
                  enum: [free, pro, enterprise]
      responses:
        "200":
          description: Tier updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [updated]
                  customer_id:
                    type: string
                  old_tier:
                    type: string
                  tier:
                    type: string
        "400":
          description: Invalid JSON, missing field, invariant violation, or bad tier.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "404":
          description: No keys found for this `customer_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # PATCH /api/v1/admin/keys/{key_id}/mcp-policy
  # -------------------------------------------------------------------------

  /api/v1/admin/keys/{key_id}/mcp-policy:
    x-internal: true
    patch:
      operationId: adminUpdateMCPPolicy
      tags: [Admin]
      summary: Change the MCP system-prompt policy for an API key
      description: |
        Sets per-key policy for `POST /api/v1/mcp/messages`:

        - `sanitize` (default): Presidio/QI redacts PII in system blocks.
        - `passthrough_audit`: PII findings are logged but the system
          prompt passes through unchanged.

        Key-scoped — affects only the specified key, not the whole
        customer. Emits `ADMIN_MCP_POLICY_CHANGE`.
      security:
        - AdminKeyAuth: []
      parameters:
        - name: key_id
          in: path
          required: true
          description: SHA256 hash of the DSA API key.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mcp_system_policy]
              properties:
                mcp_system_policy:
                  type: string
                  enum: [sanitize, passthrough_audit]
      responses:
        "200":
          description: MCP policy updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [updated]
                  key_id:
                    type: string
                  customer_id:
                    type: string
                  old_mcp_policy:
                    type: string
                  mcp_system_policy:
                    type: string
        "400":
          description: Invalid JSON, missing field, or invalid policy value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "404":
          description: Key not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "429":
          description: Admin endpoint IP-throttle (from `adminKeyGuard`).
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # -------------------------------------------------------------------------
  # POST /api/v1/admin/invariant/reset
  # -------------------------------------------------------------------------

  /api/v1/admin/invariant/reset:
    x-internal: true
    post:
      operationId: adminResetInvariant
      tags: [Admin]
      summary: Reset the isolation invariant to verified state
      description: |
        Calls `/invariant/reset` on both Sandbox A and Sandbox B, clearing
        any latched breach flags. Returns the aggregated invariant state.
        Emits `INVARIANT_RESET`. Available only when the invariant poller
        is configured.
      security:
        - AdminKeyAuth: []
      responses:
        "200":
          description: Invariant reset + current state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [verified, locked]
                  sandbox_a:
                    type: object
                  sandbox_b:
                    type: object
                  message:
                    type: string
        "401":
          description: Missing or invalid `X-Admin-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          description: Failed to aggregate invariant state from the poller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # =========================================================================
  # Website — scoped service-key endpoints for the marketing site
  # =========================================================================

  # -------------------------------------------------------------------------
  # GET /api/v1/website/customer
  # -------------------------------------------------------------------------

  /api/v1/website/customer:
    get:
      operationId: websiteLookupCustomer
      tags: [Website]
      summary: Look up a customer by email (scoped to the marketing site)
      description: |
        Narrow-scope endpoint used by the marketing site's account pages.
        Returns the customer profile plus a heavily masked DSA key (first
        8 chars only). The full key is **never** returned from this path.

        Auth uses `X-Website-Key`, a scoped service key distinct from the
        admin key (`DSA_WEBSITE_SERVICE_KEY`).
      security:
        - WebsiteKeyAuth: []
      parameters:
        - name: email
          in: query
          required: true
          schema:
            type: string
            format: email
      responses:
        "200":
          description: Customer profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer_id:
                    type: string
                  tier:
                    type: string
                  monthly_limit:
                    type: integer
                  managed_ai:
                    type: boolean
                  api_key:
                    type: string
                    description: Masked to first 8 chars (`dsa_abcd…`).
        "400":
          description: Missing `email` query parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "401":
          description: Missing or invalid `X-Website-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
        "404":
          description: No customer with this email.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"

  # =========================================================================
  # Internal — operator health / readiness / invariant probes
  # These are published for completeness but are NOT part of the public API.
  # =========================================================================

  # -------------------------------------------------------------------------
  # GET /healthz
  # -------------------------------------------------------------------------

  /healthz:
    get:
      operationId: healthz
      tags: [Internal]
      x-internal: true
      summary: Liveness + dependency health probe
      description: |
        Probes every required downstream (identity, bridge, sandbox_b,
        sanitizer) per request. Returns 200 when healthy, 503 when any
        required dependency is failing. Fail-open dependencies (audit,
        veil_witness) are reported in the JSON body but do **not** affect
        HTTP status.

        Intended for load balancers and container orchestrators — not for
        public API consumers.
      responses:
        "200":
          description: All required dependencies healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [healthy]
                  checks:
                    type: object
        "503":
          description: At least one required dependency failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [unhealthy]
                  checks:
                    type: object

  # -------------------------------------------------------------------------
  # GET /readyz
  # -------------------------------------------------------------------------

  /readyz:
    get:
      operationId: readyz
      tags: [Internal]
      x-internal: true
      summary: Readiness probe (invariant-gated)
      description: |
        Same probes as `/healthz` plus the isolation-invariant poller.
        Returns 503 until the poller has verified both sandboxes at least
        once — this prevents the load balancer from routing traffic before
        the split-knowledge guarantees are confirmed.

        Intended for load balancers and container orchestrators — not for
        public API consumers.
      responses:
        "200":
          description: Ready to serve traffic.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [healthy]
                  checks:
                    type: object
        "503":
          description: Not ready (invariant unverified or dependency failing).
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [unhealthy]
                  checks:
                    type: object

  # -------------------------------------------------------------------------
  # GET /invariant
  # -------------------------------------------------------------------------

  /invariant:
    get:
      operationId: invariant
      tags: [Internal]
      x-internal: true
      summary: Real-time isolation-invariant state
      description: |
        Exposes the isolation-invariant state of both sandboxes and the
        circuit-breaker position. Returns 200 under normal operation.
        When the invariant poller is not configured, returns
        `{"status":"verified","message":"poller not configured"}`.
        Returns 500 if the poller fails to aggregate state.

        Used by operator dashboards. Not intended for public API consumers.
      responses:
        "200":
          description: Current invariant state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sandbox_a:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [verified, locked, breached]
                      strikes:
                        type: integer
                      circuit:
                        type: string
                        enum: [closed, open, half-open]
                  sandbox_b:
                    type: object
                  message:
                    type: string
        "500":
          description: Poller failed to aggregate invariant state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DSAError"
