openapi: 3.1.0
info:
  title: FingerTrackID — Event Assessment API
  version: 1.0.0
  description: |
    Contrato público server-to-server para avaliar um evento específico de `LOGIN`,
    `WITHDRAWAL` ou `TRADE`. A resposta é síncrona e só sai depois de o assessment estar
    persistido; ela nunca reutiliza a nota de outro evento.

    O FingerTrackID devolve `recommended_action`. O cliente continua sendo o único responsável
    por decidir e executar sua ação. `INCONCLUSIVE`, `UNSCORABLE` e `FAILED` têm
    `trust_score = null`; ausência de evidência nunca aparece como confiança 100.

    **Estado de implementação:** contrato M0 publicado; runtime ainda não disponível. Este
    documento congela a integração antes do código conforme `contracts/README.md`.
  license:
    name: Proprietary

servers:
  - url: https://api.fingertrackid.dev
    description: Endpoint compartilhado atrás do ALB e Cloud Armor

tags:
  - name: assessments
    description: Avaliação síncrona e event-specific para o backend confiável do cliente

paths:
  /v1/assessments:
    post:
      tags: [assessments]
      operationId: createEventAssessment
      summary: Cria ou recupera idempotentemente o assessment do evento
      x-implementation-status: planned
      x-ftid-max-request-body-bytes: 16384
      x-ftid-max-response-body-bytes: 65536
      x-ftid-canonical-request-target:
        path: /v1/assessments
        query: FORBIDDEN
        normalization: FORBIDDEN
      x-ftid-idempotency:
        scope: INTEGRATION_CLIENT
        key_reuse: NEVER
        durable_tombstone: true
        retention_boundary: INTEGRATION_LIFETIME_OR_TENANT_ERASURE
      description: |
        **Applied history boundary.** The `200 SAME` guarantees below apply only while the
        original server-received event remains within the applied history period. An exact
        authenticated retry beyond that period returns `410 assessment-result-expired`,
        `retryable = false`, with no assessment body or new effect. Logical divergence remains
        `409`; invalid authentication remains `401`. Expiry never releases either logical ID.

        Para uma nova chave lógica, cria evento, snapshot de features e assessment na mesma
        transação e responde `201`. A mesma `idempotency_key` com o mesmo digest semântico
        responde `200` com exatamente o assessment persistido, sem recalcular. Reuso da chave,
        do `external_event_id` com conteúdo divergente responde `409 idempotency-conflict` sem
        efeito parcial. Conflito entre referências de identidade/sessão válidas responde
        `409 binding-conflict`.

        O `request_id` identifica a tentativa de transporte e muda em cada retry. O
        `idempotency_key` no body identifica a operação lógica e permanece igual. Seu escopo é
        o `integration_client` derivado da credencial; dentro desse escopo, tanto a chave quanto
        `external_event_id` não podem ser reutilizados durante a vida da integração. Tombstones
        mínimos, sem o body bruto, preservam essa garantia após a retenção do payload; o
        offboarding/erasure do tenant encerra o namespace conforme a política de retenção. Se já existir
        um assessment — inclusive `INCONCLUSIVE`, `UNSCORABLE` ou `FAILED` — ele sempre traz
        `retryable = false`. Somente um Problem anterior ao commit de domínio/assessment pode
        autorizar retry; a proteção de transporte pode já ter commitado o nonce da tentativa.

        Depois de autenticar, validar o envelope e reservar o `request_id`, o serviço resolve
        os dois IDs e o digest lógico. Um hit `SAME` devolve o assessment persistido mesmo se o
        `occurred_at` original já estiver fora da janela atual. Somente uma operação `NEW` exige
        `occurred_at` dentro de ±300 segundos do `received_at` medido pelo servidor. Backfill é
        outro fluxo. O campo `deadline_ms` é obrigatório e nullable: `null` seleciona o default
        da v1 (5000 ms em LOGIN, 1000 ms em WITHDRAWAL, 750 ms em TRADE; LOGIN passou de 750 para
        1000 ms e depois para 5000 ms em 14/10/2026, D196, e um corpo assinado com qualquer valor
        das faixas anteriores continua válido e mantém exatamente esse teto); um número só reduz
        esse budget dentro da faixa publicada. O relógio monotônico da aplicação começa na entrada
        do `public-api`; latência anterior no ALB pertence ao SLI externo. Num LOGIN com `null`, o
        timeout HTTP do cliente precisa passar de uns 6 segundos (o budget mais a rede). Esses
        budgets não são SLA comercial e não alteram score, evidência ou identidade idempotente da
        operação.

        O request-target precisa ser exatamente `/v1/assessments`: query string, trailing slash,
        dot-segment, barra duplicada e path percent-encoded são recusados antes do roteamento. O
        conteúdo HTTP tem no máximo 16384 octetos após remover o framing de transferência; o
        limite é inclusivo e os metadados de chunks não contam. O stream é contado mesmo sem
        `Content-Length` ou com transferência chunked e é interrompido no primeiro octeto
        excedente, antes de resolver segredo, calcular HMAC ou parsear JSON. `Content-Length`
        serve apenas para rejeição antecipada; o stream real é autoritativo. Qualquer
        `Content-Encoding`, inclusive `identity`, é recusado. O JSON deve ser UTF-8 sem BOM,
        chaves duplicadas ou conteúdo posterior ao documento. O HMAC e o parser recebem os
        mesmos octetos. Nenhuma chamada de provider externo acontece inline.

        Every response of this operation (201, 200 and every Problem) carries at most 65536
        octets of content (`x-ftid-max-response-body-bytes`). The bound is inclusive and equals
        the one `feedback.v1.yaml` publishes for the `assessment` webhook body, which is this
        same document. The response JSON Schema alone does not bound the serialized size; this
        ceiling does. The server never persists an assessment whose response would exceed it
        and never sends a truncated response. A client may stop reading at the first octet past
        the ceiling and handle the attempt as indeterminate, recovering with a new signed attempt
        of the same logical request.
      security:
        - ftidHmacV1: []
      parameters:
        - $ref: '#/components/parameters/FTIDTimestamp'
        - $ref: '#/components/parameters/FTIDRequestId'
        - $ref: '#/components/parameters/FTIDExternalEventId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: ../json-schema/event-assessment-request.v1.schema.json
            examples:
              login:
                summary: LOGIN conclusivo
                externalValue: ../examples/event-assessment-v1.examples.json#/requests/login
              withdrawal:
                summary: WITHDRAWAL conclusivo
                externalValue: ../examples/event-assessment-v1.examples.json#/requests/withdrawal
              trade:
                summary: TRADE conclusivo sem provider inline
                externalValue: ../examples/event-assessment-v1.examples.json#/requests/trade
              withoutCollector:
                summary: LOGIN válido sem coleta, que resulta em INCONCLUSIVE
                externalValue: ../examples/event-assessment-v1.examples.json#/requests/login_without_collector
      responses:
        '201':
          description: Novo assessment persistido
          headers:
            X-Correlation-Id:
              $ref: '#/components/headers/CorrelationId'
          content:
            application/json:
              schema:
                $ref: ../json-schema/event-assessment.v1.schema.json
              examples:
                login:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/login_completed
                withdrawal:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/withdrawal_completed
                trade:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/trade_completed
                inconclusive:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/inconclusive_collector_not_loaded
                providerUnavailable:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/provider_unavailable
        '200':
          description: Exact retry within the applied history period; returns the persisted assessment without recalculation
          headers:
            X-Correlation-Id:
              $ref: '#/components/headers/CorrelationId'
          content:
            application/json:
              schema:
                $ref: ../json-schema/event-assessment.v1.schema.json
              examples:
                duplicate:
                  externalValue: ../examples/event-assessment-v1.examples.json#/responses/login_completed
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '410':
          $ref: '#/components/responses/ResultExpired'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

components:
  securitySchemes:
    ftidHmacV1:
      type: apiKey
      in: header
      name: X-FTID-Signature
      x-ftid-credential-locator: key_id
      x-ftid-derived-scope:
        - tenant_id
        - application_id
        - environment
        - integration_client_id
        - credential_version
        - credential_purpose
        - capabilities
      description: |
        Formato exato `v1=<key_id>:<mac-hex>`. `key_id` é o único locator público: o UUIDv7
        global da credencial determina tenant, aplicação, ambiente, integration client, versão,
        finalidade inbound e capabilities candidatas. Esse escopo só se torna autoridade depois
        de o MAC ser validado; valor inexistente, revogado, suspenso ou assinatura incorreta
        produz a mesma resposta `authentication-failed`. O segredo nunca viaja. O MAC é
        HMAC-SHA-256 sobre dez linhas UTF-8:

        ```text
        FTID-HMAC-SHA256-V1
        key_id
        POST
        /v1/assessments
        timestamp_epoch_seconds
        external_event_id:<valor>
        request_id:<uuidv7>
        application/json
        <linha vazia para Content-Encoding não usado>
        sha256_hex(bytes exatos do body)
        ```

        Não reserializar JSON antes de calcular o hash. O `Content-Type` é exatamente
        `application/json`. Headers do protocolo legado, nonce, `X-Client-Id`, compressão e
        múltiplas assinaturas são recusados; não existe downgrade automático.
        Depois de validar o MAC de uma candidata conhecida, o servidor reserva `request_id`
        antes de decidir o skew do timestamp. MAC inválido não cria nonce. Se a dependência de
        segredo estiver indisponível, um latch global de saúde uniformiza tentativas
        estruturalmente válidas em `503`, sem revelar se o `key_id` existe.
        O vetor normativo está em `assessment-hmac-v1-test-vector.json`.

  parameters:
    FTIDTimestamp:
      name: X-FTID-Timestamp
      in: header
      required: true
      description: |
        Epoch UTC em segundos, inteiro sem zero à esquerda e numericamente entre 0 e
        253402300799 (último segundo de 9999 UTC). A comparação numérica é obrigatória além do
        pattern; tolerância máxima de aceitação ±300 s.
      x-ftid-maximum-epoch-seconds: 253402300799
      schema:
        type: string
        maxLength: 12
        pattern: '^(?:0|[1-9][0-9]{0,10}|1[0-9]{11}|2[0-4][0-9]{10}|25[0-2][0-9]{9}|253[0-3][0-9]{8}|25340[0-1][0-9]{6}|253402[0-2][0-9]{5}|253402300[0-7][0-9]{2})$'
    FTIDRequestId:
      name: X-FTID-Request-Id
      in: header
      required: true
      description: UUIDv7 canônico da tentativa. Um retry lógico usa um novo request_id.
      schema:
        type: string
        pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
    FTIDExternalEventId:
      name: X-FTID-External-Event-Id
      in: header
      required: true
      description: Deve ser byte a byte igual ao external_event_id do body e da string assinada.
      schema:
        type: string
        pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'

  headers:
    CorrelationId:
      description: Identificador opaco para suporte e correlação; nunca contém PII.
      schema:
        type: string
        minLength: 1
        maxLength: 128

  responses:
    BadRequest:
      description: |
        Request inválido. `type` é `validation-failed`, `event-context-invalid` ou
        `occurred-at-out-of-window`; não há assessment e `retryable` é falso.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type:
                    enum:
                      - https://fingertrackid.dev/problems/validation-failed
                      - https://fingertrackid.dev/problems/event-context-invalid
                      - https://fingertrackid.dev/problems/occurred-at-out-of-window
                  status: { const: 400 }
                required: [type, status]
    Unauthorized:
      description: |
        `authentication-failed` uniformiza envelope HMAC ausente/malformado, key desconhecida,
        credencial/cliente suspenso ou revogado e MAC incorreto. `timestamp-out-of-window` e
        `request-replayed` autorizam novo envelope assinado; nunca reutilize o request_id.
      headers:
        WWW-Authenticate:
          required: true
          description: Challenge estável do protocolo HMAC v1.
          schema: { type: string, const: 'FTID-HMAC-V1 realm="assessments"' }
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type:
                    enum:
                      - https://fingertrackid.dev/problems/authentication-failed
                      - https://fingertrackid.dev/problems/timestamp-out-of-window
                      - https://fingertrackid.dev/problems/request-replayed
                  status: { const: 401 }
                required: [type, status]
    Forbidden:
      description: |
        A credencial inbound é válida, mas o integration client não possui
        `assessment:write`. `type` é `forbidden`, status `403` e `retryable = false`; trocar
        somente request_id não concede acesso. Action e outcome usam capabilities separadas.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/forbidden }
                  status: { const: 403 }
                required: [type, status]
    Conflict:
      description: |
        `idempotency-conflict` cobre reuso divergente de idempotency key ou external event ID.
        `binding-conflict` cobre Party, Account ou sessão válidos mas incompatíveis. Nenhum
        efeito parcial é criado e ambos exigem reconciliação, não retry automático.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type:
                    enum:
                      - https://fingertrackid.dev/problems/idempotency-conflict
                      - https://fingertrackid.dev/problems/binding-conflict
                  status: { const: 409 }
                required: [type, status]
    ResultExpired:
      description: |
        An authenticated exact logical retry whose original server-received event is outside
        the applied history period returns `410 assessment-result-expired`, retryable false.
        Authentication and logical conflict checks precede expiry. The durable logical
        tombstone remains; no assessment, scoring, protocol assignment or new usage is created.
        The reply carries no historical payload, assessment ID, detail or instance URI.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/assessment-result-expired }
                  status: { const: 410 }
                required: [type, status]
          examples:
            expired:
              externalValue: ../examples/event-assessment-v1.examples.json#/problems/assessment_result_expired
    PayloadTooLarge:
      description: |
        Body bruto maior que 16384 bytes, contado antes de segredo, HMAC e parse; nenhum
        assessment é criado. Reduza o body e assine novamente.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/payload-too-large }
                  status: { const: 413 }
                required: [type, status]
    UnsupportedMediaType:
      description: |
        `Content-Type` diferente do valor exato `application/json` ou qualquer
        `Content-Encoding`. `type` é `unsupported-media-type`, status `415` e
        `retryable = false` para este request; corrija e assine novamente.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/unsupported-media-type }
                  status: { const: 415 }
                required: [type, status]
    TooManyRequests:
      description: |
        Limite autenticado e isolado do assessment atingido antes do efeito de domínio.
        O nonce desta tentativa já foi consumido e commitado, mas a rejeição
        não cria assessment nem receipt de autorização/efeito permanente. Preserve
        `idempotency_key` e `external_event_id`; após `Retry-After`, envie novo `request_id`,
        timestamp atual e nova assinatura.
      headers:
        Retry-After:
          required: true
          description: Segundos inteiros antes do novo envelope da mesma operação lógica.
          schema: { type: integer, minimum: 1 }
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/rate-limited }
                  status: { const: 429 }
                required: [type, status]
    InternalError:
      description: |
        Falha interna conhecida, não transitória e confirmadamente anterior ao commit:
        `internal`, status `500`, `retryable = false`. Estado desconhecido ou indisponibilidade
        transitória usa `assessment-unavailable`/`503`.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/internal }
                  status: { const: 500 }
                required: [type, status]
    ServiceUnavailable:
      description: |
        Timeout, dependência/capacidade indisponível ou estado de commit desconhecido compartilham
        `assessment-unavailable`, status `503` e `retryable = true`. Repita a operação lógica
        com novo request_id e respeite Retry-After; idempotência resolve um commit concluído sem
        resposta. Quando a falha ocorre antes de confirmar o MAC — por exemplo, Secret Manager
        indisponível — o servidor pode não ter consumido o envelope antigo; não o reutilize.
        O latch global de saúde devolve a mesma resposta a toda tentativa HMAC estruturalmente
        válida durante a indisponibilidade. O detalhe interno permanece apenas em telemetria protegida.
      headers:
        Retry-After:
          required: true
          description: Segundos inteiros antes do próximo retry.
          schema: { type: integer, minimum: 1 }
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: ../json-schema/assessment-problem.v1.schema.json
              - type: object
                properties:
                  type: { const: https://fingertrackid.dev/problems/assessment-unavailable }
                  status: { const: 503 }
                required: [type, status]
          examples:
            timeout:
              externalValue: ../examples/event-assessment-v1.examples.json#/problems/timeout_before_assessment
