openapi: 3.1.0
info:
  title: AEDOC API
  version: 0.1.0-mvp
  description: |
    API pública da AEDOC — assinatura eletrônica **avançada** (Lei 14.063/2020,
    art. 4º, II), trilha de auditoria append-only, hash SHA-256 e verificação
    pública por hash.

    Contrato **spec-first**: esta especificação é a fonte da verdade da API.
    Toda mudança de comportamento nasce aqui antes de chegar ao código — o que
    está documentado é o que a API faz.

    ### Enquadramento jurídico (não confundir)
    A plataforma implementa **avançada** (conjunto de evidências: hash + trilha +
    página de auditoria). **NÃO** é PAdES/ICP-Brasil (qualificada) — essa é uma
    fase futura, explícita. A
    verificação pública reporta **íntegro / alterado / expirado / não encontrado**
    (nunca "válido", que implicaria validação ICP).

    ### Autenticação
    - **cookieAuth**: Sanctum stateful (painel/SPA). O SPA faz `GET /sanctum/csrf-cookie`,
      depois `POST /v1/auth/login`; as chamadas seguintes usam o cookie de sessão
      HttpOnly + header `X-XSRF-TOKEN`.
    - **bearerApiKey**: chave de API por tenant
      (`Authorization: Bearer aedoc_live_…`), tabela `api_keys` própria (não
      reutiliza `personal_access_tokens`). O MESMO contrato /v1 do painel, com
      **escopos** por chave (`documents:read|write|sign`, `signers:write`,
      `signers:link`, `fields:write`, `audit:read`, `templates:read|write`,
      `webhooks:read|write`, `billing:read`) e **rate limit por
      plano** (bucket por tenant — Free 10 · Professional 60 · Enterprise
      200 req/min; 429 ao estourar). O segredo aparece UMA única vez na
      criação; a gestão de chaves (`/v1/api-keys`) é exclusiva da sessão do
      painel. Superfícies de conta/painel (auth, dashboard, tenant, api-keys)
      NÃO aceitam Bearer.

    ### Idempotência inbound
    Os POSTs de criação aceitam o header **`Idempotency-Key`** (opt-in): repetir
    a mesma chave em até 24h devolve a MESMA resposta (header
    `Idempotent-Replayed: true`), sem duplicar o recurso — retry de rede seguro
    para integradores.

    ### Webhooks
    Eventos do ciclo de vida (nomes dotted, ex.: `document.signed`) são entregues
    com assinatura HMAC no header `X-AEDOC-Signature` — ver a seção `webhooks`
    desta spec e o guia de integração em https://aedoc.com.br/docs/guia.
    Payload THIN: ids + links, nunca dados pessoais.
  contact:
    name: AEDOC
    url: https://aedoc.com.br
  license:
    name: Proprietary

servers:
  - url: http://localhost
    description: Dev local (Sail/Herd)
  - url: https://api.aedoc.com.br
    description: Produção

tags:
  - name: Auth
    description: Onboarding, sessão e verificação de e-mail
  - name: Conta
    description: Usuário corrente, tenant e assinatura
  - name: Documentos
    description: Envelope (documento + arquivos + signatários + campos) e ciclo de vida
  - name: Templates
    description: Templates de documento (arquivos-base + papéis + campos) e materialização em envelope
  - name: Signatários
    description: Signatários do documento e reenvio de magic link
  - name: Métricas
    description: Read model do painel — derivado das tabelas de domínio/trilha por leitura pura (nunca onera nem altera a trilha legal)
  - name: Verificação
    description: Verificação pública de integridade por hash
  - name: Chaves de API
    description: Gestão de chaves Bearer da API pública — exclusiva da sessão do painel
  - name: Webhooks
    description: Endpoints de webhook, rotação de segredo e histórico de entregas
  - name: Cobrança
    description: Assinatura do plano via Stripe — checkout/portal hospedados e webhook do provedor; exclusiva da sessão do painel
  - name: Backoffice
    description: Operador da plataforma AEDOC (/v1/admin) — cross-tenant, is_platform_admin + 2FA obrigatório
  - name: Membros
    description: Gestão de usuários/permissões por tenant — convite, papéis (admin/member/financeiro/operador), assentos; exclusiva da sessão do painel
  - name: Relatórios
    description: Exportação (CSV/PDF) de documentos e uso para prestação de contas; PII mascarada, sem conteúdo
  - name: Convites
    description: Aceite público de convite de membro — link assinado, sem sessão
  - name: Portal do signatário
    description: Área pública do signatário — histórico cross-tenant por identidade de e-mail (OTP), sem conta de tenant; sessão de alta entropia como capability do canal RLS-safe
  - name: Site
    description: Endpoints do site público de marketing (aedoc.com.br) — sem auth; formulário de contato

# Padrão global: domínio documental e webhooks aceitam sessão OU chave Bearer
# Superfícies de conta/painel sobrescrevem para cookieAuth apenas;
# rotas públicas sobrescrevem com security: [].
security:
  - cookieAuth: []
  - bearerApiKey: []

paths:
  # ----------------------------------------------------------------- Auth
  /v1/contact:
    post:
      tags: [Site]
      summary: Envia uma mensagem do formulário de contato do site
      description: |
        Formulário de contato público de aedoc.com.br. Sem auth. Grava a mensagem
        e notifica o e-mail interno. Anti-abuso: honeypot (campo `website`, oculto
        no front — se preenchido, resposta 201 silenciosa sem gravar), rate limit
        por IP e, quando habilitado, Cloudflare Turnstile (`turnstile_token`).
        A resposta é sempre 201 uniforme, exceto validação (422).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactRequest'
      responses:
        '201':
          description: Mensagem recebida.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [status]
                    properties:
                      status: { type: string, enum: [received] }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit de contato excedido. }

  /v1/auth/register:
    post:
      tags: [Auth]
      summary: Onboarding auto-serviço (cria empresa + owner)
      description: Cria a empresa (tenant), o usuário owner, a role admin e uma assinatura Free; dispara verificação de e-mail do owner.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: Empresa criada; e-mail de confirmação enviado.
          content:
            application/json:
              schema:
                type: object
                required: [message, user, tenant]
                properties:
                  message: { type: string }
                  user: { $ref: '#/components/schemas/User' }
                  tenant: { $ref: '#/components/schemas/Tenant' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/auth/login:
    post:
      tags: [Auth]
      summary: Login (sessão Sanctum stateful)
      description: |
        Com 2FA CONFIRMADO na conta, o mesmo POST deve
        trazer também `code` (TOTP) ou `recovery_code` (single-use). Sem o 2º
        fator → 422 com a chave `two_factor` (o front reapresenta o formulário
        com o campo de código). A sessão só é criada após o 2º fator provado.
        Rate limit: 5/min por e-mail+IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Autenticado; cookie de sessão emitido.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/User' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit de login excedido. }

  /v1/auth/forgot-password:
    post:
      tags: [Auth]
      summary: Solicita link de redefinição de senha (e-mail)
      description: |
        Redesign Etapa 3. Resposta SEMPRE genérica (200) — e-mail inexistente
        não é distinguível de existente (anti-enumeração). O broker aplica
        throttle por usuário (60s) além do rate limit por IP (5/min).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Pedido registrado; se a conta existir, o e-mail foi enviado.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: string }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit excedido. }

  /v1/auth/reset-password:
    post:
      tags: [Auth]
      summary: Redefine a senha com o token do e-mail
      description: |
        Consome o token enviado por e-mail (single-use, expira em 60 min).
        Token inválido/expirado e e-mail sem conta retornam o MESMO 422
        genérico na chave `email`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, email, password, password_confirmation]
              properties:
                token: { type: string }
                email: { type: string, format: email }
                password: { type: string, format: password }
                password_confirmation: { type: string, format: password }
      responses:
        '200':
          description: Senha redefinida.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: string }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit excedido. }

  /v1/auth/two-factor:
    post:
      tags: [Auth]
      summary: Ativa o 2FA (gera secret + QR — pendente até o confirm)
      security:
        - cookieAuth: []
      description: |
        FECHAMENTO DA FASE 1. Operação sensível: exige a SENHA ATUAL no corpo
        (decisão documentada — sem fluxo de password.confirm por sessão).
        Devolve o QR (SVG) e a setup key para o app autenticador; o 2FA só
        vale após `POST /v1/auth/two-factor/confirm` provar um TOTP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        '201':
          description: Secret gerado (estado pendente).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      qr_code_svg: { type: string }
                      setup_key: { type: string }
                      confirmed: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Auth]
      summary: Desativa o 2FA (exige a senha atual)
      security:
        - cookieAuth: []
      description: |
        Admins voltam a ser bloqueados nas rotas de escrita até reativar
        (política de enforcement do 2FA de administradores).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        '200':
          description: 2FA desativado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enabled: { type: boolean }
                      confirmed: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/auth/two-factor/confirm:
    post:
      tags: [Auth]
      summary: Confirma o 2FA provando um TOTP do app autenticador
      security:
        - cookieAuth: []
      description: |
        Sucesso devolve os recovery codes (momento canônico de salvá-los).
        Rate limit 5/min por usuário (anti brute force da janela TOTP).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, maxLength: 12 }
      responses:
        '200':
          description: 2FA confirmado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      confirmed: { type: boolean }
                      recovery_codes:
                        type: array
                        items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit excedido. }

  /v1/auth/two-factor/recovery-codes:
    get:
      tags: [Auth]
      summary: Lista os recovery codes (só com 2FA confirmado)
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Códigos de recuperação atuais.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recovery_codes:
                        type: array
                        items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
    post:
      tags: [Auth]
      summary: Regenera os recovery codes (exige a senha atual; invalida os antigos)
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        '200':
          description: Novo conjunto de códigos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recovery_codes:
                        type: array
                        items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/auth/logout:
    post:
      tags: [Auth]
      summary: Logout (encerra a sessão)
      security:
        - cookieAuth: []
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/auth/email/verify/{id}/{hash}:
    get:
      tags: [Auth]
      summary: Confirma o e-mail do owner (URL assinada)
      description: A URL assinada é a prova; não exige sessão. Ao confirmar, redireciona para o front.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      responses:
        '302':
          description: E-mail confirmado; redireciona para o SPA.
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/auth/email/verification-notification:
    post:
      tags: [Auth]
      summary: Reenvia o e-mail de verificação
      security:
        - cookieAuth: []
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/sign/{token}:
    get:
      tags: [Signatários]
      summary: Resolve o magic link e monta a página pública de aceite (idempotente)
      description: |
        Acesso PÚBLICO do signatário (não é usuário do tenant). A URL é **assinada**
        (HMAC do APP_KEY — integridade + expiração); o `token` é **hasheado** (sha256)
        em repouso. O parâmetro `tenant`, também assinado, seta o contexto de RLS.

        Leitura **PURA e idempotente** (prefetch-safe): NÃO consome o
        token, NÃO registra abertura nem emite evento (scanners de e-mail não
        queimam o link nem geram "visualizado" falso). A abertura é registrada
        pelo `POST /opened`, disparado do browser do signatário; o single-use
        acontece na AÇÃO (`/accept` ou `/decline`). Devolve tudo que a página de
        aceite precisa: documento + PDF via `temporaryUrl` (TTL 5 min) + campos
        do signatário + consentimento versionado + URLs assinadas das ações.
        Reaberto após assinar/recusar, mostra o estado terminal (`actions: null`).
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Página de aceite resolvida (CPF/telefone mascarados).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/SignerAcceptance' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/sign/{token}/opened:
    post:
      tags: [Signatários]
      summary: Registra a abertura da página de aceite pelo signatário (idempotente)
      description: |
        Disparado pelo BROWSER do signatário ao carregar a página pública — a
        evidência (IP/user-agent) registrada em `document_opened` é a do
        dispositivo real, não a do servidor que renderiza a página. Só a
        primeira abertura transiciona o signatário para `viewed`, deriva o
        status do documento e emite `document_opened` (→ webhook
        `document.viewed`); reaberturas e estados terminais são no-op.
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      responses:
        '204':
          description: Abertura registrada (ou no-op idempotente).
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/sign/{token}/otp/request:
    post:
      tags: [Signatários]
      summary: Envia o código de verificação (OTP) pelo canal escolhido
      description: |
        2º fator do gate jurídico da "avançada" (Lei 14.063/2020, art. 4º,
        II, "b"). Gera um código de 6 dígitos, guarda só o HMAC-SHA256
        (pepper de config) e entrega pelo CANAL do desafio: o signatário pode
        escolher (`channel` no corpo, validado contra
        `otp.available_channels` do GET — WhatsApp primeiro, por ser o mais
        rápido); sem escolha, vale o canal do método configurado. Resposta
        GENÉRICA: não confirma nem nega existência/entrega do contato.
        Reenvio respeita cooldown e SUPERSEDE o código anterior. Rate limit
        por signatário e por IP. Grava `otp_requested` na trilha com o canal
        REAL; o certificado rotula o canal CONSUMADO.
        Disponível apenas quando é a vez do signatário (ordem sequencial).
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                channel:
                  type: string
                  enum: [email, sms, whatsapp]
                  description: Canal escolhido pelo signatário — precisa estar em `otp.available_channels`; omitido, vale o canal do método.
      responses:
        '202':
          description: Solicitação aceita (resposta genérica).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sent_to_masked: { type: string }
                      channel:
                        type: string
                        enum: [email, sms, whatsapp]
                        description: Canal REALMENTE usado no desafio — o fallback honesto pode trocar o canal escolhido/configurado por e-mail.
                      cooldown_seconds: { type: integer }
                      expires_in_minutes: { type: integer }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit excedido (por signatário ou por IP). }

  /v1/sign/{token}/otp/verify:
    post:
      tags: [Signatários]
      summary: Valida o código de verificação (OTP) do signatário
      description: |
        Comparação constant-time (hash_equals) contra o HMAC guardado.
        Tentativa errada incrementa `attempts` (persistido mesmo com 422);
        após `max_attempts` o desafio TRAVA (peça novo código). Código é
        single-use (`consumed_at`); revalidar um desafio consumado é
        idempotente. Sucesso grava `otp_validated` na trilha (→ webhook
        `signer.authenticated`) e libera o accept. Mensagens genéricas —
        não revelam tentativas restantes nem distinguem errado de expirado.
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, maxLength: 12, description: Código de 6 dígitos recebido por e-mail }
      responses:
        '200':
          description: Código validado (ou já validado — idempotente).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      validated: { type: boolean }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { description: Rate limit excedido (por signatário ou por IP). }

  /v1/sign/{token}/accept:
    post:
      tags: [Signatários]
      summary: Assina o documento (público, assinado, idempotente — consome o magic link)
      description: |
        Aceite probatório do signatário. Consome o magic link
        (**single-use** — só a 1ª ação vence) e, na MESMA transação, grava a
        linha de `signatures` (consentimento versionado exato + hash da captura +
        fingerprint `signature_hash`), os eventos `terms_accepted` e
        `signature_created` (→ webhook `document.partially_signed`) e deriva o
        status do documento (1+ assinou e nem todos → `partially_signed`).
        Idempotente (R21): retry/duplo-clique não duplica nada.

        A captura chega SEMPRE como imagem data URI PNG/JPEG (canvas desenhado,
        nome digitado renderizado no cliente ou upload convertido), validada por
        magic bytes e armazenada em S3 privado.
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AcceptSignatureRequest'
      responses:
        '201':
          description: Assinatura registrada (ou replay idempotente do estado).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signer: { $ref: '#/components/schemas/Signer' }
                      signature: { $ref: '#/components/schemas/Signature' }
                      document:
                        type: object
                        properties:
                          id: { type: string }
                          status: { $ref: '#/components/schemas/DocumentStatus' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/sign/{token}/decline:
    post:
      tags: [Signatários]
      summary: Recusa a assinatura com motivo (público, assinado, idempotente)
      description: |
        Recusa do signatário. Consome o magic link, marca o
        signatário como `declined` (terminal), encerra o envelope
        (`document.declined`), grava o evento `declined` com o motivo e notifica
        o remetente (template versionado) — tudo na mesma transação.
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: tenant, in: query, required: true, schema: { type: string } }
        - { name: expires, in: query, required: false, schema: { type: integer } }
        - { name: signature, in: query, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  minLength: 3
                  maxLength: 1000
                  description: Motivo da recusa (vira evidência e é enviado ao remetente).
      responses:
        '200':
          description: Recusa registrada (ou replay idempotente do estado).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signer: { $ref: '#/components/schemas/Signer' }
                      document:
                        type: object
                        properties:
                          id: { type: string }
                          status: { $ref: '#/components/schemas/DocumentStatus' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # ----------------------------------------------------------------- Conta
  /v1/me:
    get:
      tags: [Conta]
      summary: Usuário autenticado + empresas + tenant corrente
      security:
        - cookieAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/tenant:
    get:
      tags: [Conta]
      summary: Tenant corrente + assinatura ativa
      security:
        - cookieAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Tenant' }
                  subscription:
                    oneOf:
                      - { $ref: '#/components/schemas/Subscription' }
                      - { type: 'null' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/tenant/branding:
    get:
      tags: [Conta]
      summary: Marca (white-label) do tenant corrente
      description: >-
        Logo + cor primária do tenant. Gestão SÓ por sessão + 2FA de
        admin. A marca é identidade VISUAL — não afeta a moldura legal.
      security:
        - cookieAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BrandingSettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [Conta]
      summary: Atualiza a marca do tenant (cor e/ou logo)
      description: >-
        `multipart/form-data`: `primary_color` (hex), `logo` (PNG/JPEG) e
        `remove_logo` (bool). Envio vazio é no-op. Exige 2FA de admin.
      security:
        - cookieAuth: []
      requestBody:
        required: false
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                primary_color: { type: string, example: '#0A5C36' }
                logo: { type: string, format: binary }
                remove_logo: { type: boolean }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BrandingSettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/tenant/switch:
    post:
      tags: [Conta]
      summary: Troca a empresa ativa da sessão
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id]
              properties:
                tenant_id: { type: string, example: 01kweypxpsvskddwmsgn905exz }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Tenant' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/subscriptions:
    get:
      tags: [Conta]
      summary: Lista assinaturas do tenant (cursor)
      security:
        - cookieAuth: []
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/subscriptions/{subscription}:
    get:
      tags: [Conta]
      summary: Detalhe da assinatura
      security:
        - cookieAuth: []
      parameters:
        - { name: subscription, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ----------------------------------------------------------------- Documentos
  /v1/dashboard:
    get:
      tags: [Métricas]
      summary: Métricas do painel (read model — leitura pura)
      security:
        - cookieAuth: []
      description: |
        Contadores e agregados do tenant no período (default: mês corrente).
        Derivado por queries agregadas sobre as tabelas de domínio e a trilha
        append-only — LEITURA pura: nenhuma projeção escreve na trilha legal.
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
          description: 'Início do período (inclusive). Default: 1º dia do mês corrente.'
        - name: to
          in: query
          schema: { type: string, format: date }
          description: 'Fim do período (inclusive). Default: hoje.'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DashboardMetrics' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/documents:
    get:
      tags: [Documentos]
      summary: Lista documentos do tenant (cursor)
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/DocumentStatus' }
        - name: q
          in: query
          description: Busca por título (ILIKE, case-insensitive; wildcards tratados como literais).
          schema: { type: string }
        - name: from
          in: query
          description: Filtra documentos criados a partir desta data (inclusive, início do dia).
          schema: { type: string, format: date }
        - name: to
          in: query
          description: Filtra documentos criados até esta data (inclusive, fim do dia).
          schema: { type: string, format: date }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Document' }
    post:
      tags: [Documentos]
      summary: Cria um documento (envelope)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDocumentRequest' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    get:
      tags: [Documentos]
      summary: Detalhe do documento
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Documentos]
      summary: Remove (soft delete) o documento
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/documents/{id}/files:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    post:
      tags: [Documentos]
      summary: Anexa um arquivo PDF (armazenado em S3 privado; hash SHA-256 original)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Arquivo anexado; hash original registrado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentFile' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/files/{file_id}/url:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
      - name: file_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Documentos]
      summary: Emite uma URL assinada temporária para o arquivo (TTL 5 min)
      description: |
        Único caminho de acesso ao conteúdo do PDF, que vive em bucket S3
        privado (Block Public Access). Nunca há URL pública/permanente
        (princípio 7). A URL expira em `expires_in` segundos.
      responses:
        '200':
          description: URL assinada emitida.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemporaryUrl' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/documents/{id}/signers:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    post:
      tags: [Signatários]
      summary: Adiciona um signatário
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignerInput' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Signer' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/signers/{signer_id}:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
      - $ref: '#/components/parameters/SignerId'
    patch:
      tags: [Signatários]
      summary: Atualiza um signatário (draft/ready — 422 depois do envio)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignerUpdate' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Signer' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Signatários]
      summary: Remove um signatário
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/documents/{id}/signers/{signer_id}/resend:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
      - $ref: '#/components/parameters/SignerId'
    post:
      tags: [Signatários]
      summary: Reenvia o magic link a um signatário
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202': { $ref: '#/components/responses/Message' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/documents/{id}/signers/{signer_id}/signing-link:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
      - $ref: '#/components/parameters/SignerId'
    post:
      tags: [Signatários]
      summary: Emite o magic link do signatário para entrega pelo integrador
      description: |
        Devolve a URL da página pública de assinatura (`{app_url}/assinar/…`)
        para que **o integrador** a entregue ao signatário pelo canal dele
        (app próprio, QR code, SMS transacional). É o link do SIGNATÁRIO — não
        confundir com `links.audit`/`links.document` do webhook, que são
        endpoints de API com Bearer.

        **A AEDOC não notifica ninguém nesta chamada**: nenhum e-mail,
        nenhum WhatsApp. Funciona com o envelope ainda em `ready` (não exige
        `/send` antes) e marca o signatário como convidado.

        **O link é retornado UMA ÚNICA VEZ** — só o SHA-256 é persistido, então
        nem a AEDOC consegue relê-lo depois. Guarde a URL ao recebê-la.

        **Emitir de novo NÃO invalida o link anterior**: os dois passam a valer
        até expirarem. Isso é deliberado — um QR code já impresso ou
        distribuído continua funcionando. Se precisar REVOGAR um link vazado,
        use `POST .../signers/{id}/resend`, que é o único caminho que invalida
        os links pendentes do signatário (e dispara o convite pela AEDOC).

        **Exige 2º fator no signatário** (`auth_method` de OTP). Um link
        auto-entregue a signatário sem segundo fator rebaixaria a assinatura
        de *avançada* para *simples*; a emissão é recusada com 422 em vez de
        produzir um envelope de tier menor silenciosamente.

        A trilha registra a emissão e QUEM a pediu.

        Não aceita `Idempotency-Key`: a resposta carrega o link em claro e não
        pode ser cacheada. Repetir a chamada é seguro — gera outro link válido,
        sem invalidar o anterior.
      responses:
        '201':
          description: Link emitido (o anterior, se havia, foi invalidado)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/SigningLink' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/fields:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    get:
      tags: [Documentos]
      summary: Lista os campos posicionados do documento (editor)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Field' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Documentos]
      summary: Adiciona um campo posicionado (editor drag-drop)
      description: |
        Coordenadas na MESMA convenção que o renderer do PDF final consome:
        **milímetros absolutos da página**, origem no canto
        superior esquerdo (`x` → direita, `y` → baixo). Só em `draft`/`ready`
        (422 depois do envio). Campo sem posição válida cai no carimbo padrão
        de rodapé na finalização.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FieldInput' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Field' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/fields/{field_id}:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
      - $ref: '#/components/parameters/FieldId'
    patch:
      tags: [Documentos]
      summary: Reposiciona/altera um campo (draft/ready — 422 depois do envio)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FieldUpdate' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Field' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Documentos]
      summary: Remove um campo (draft/ready — 422 depois do envio)
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/send:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    post:
      tags: [Documentos]
      summary: Envia o documento aos signatários (idempotente)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: Enfileirado para envio.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '402':
          description: |
            Limite do plano atingido E assinatura inadimplente
            (`past_due`) — o CTA é regularizar o pagamento no portal de
            cobrança. `422` continua sendo a resposta do Free / assinatura em
            dia com limite estourado (CTA de upgrade).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  code: { type: string, enum: [payment_required] }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/documents/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    post:
      tags: [Documentos]
      summary: Cancela o documento
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      description: |
        Só envelopes em andamento (draft/ready/sent/viewed/partially_signed).
        `processing`/terminal → 422 (evidência concluída não se cancela).
        Idempotente: cancelar um já cancelado devolve o estado atual.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/documents/{id}/audit:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    get:
      tags: [Documentos]
      summary: Trilha de auditoria do documento (append-only)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AuditEvent' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/documents/{id}/download:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    get:
      tags: [Documentos]
      summary: URL assinada temporária do PDF final
      description: >-
        Disponível apenas quando o documento está `signed` (artefato final
        gerado uma única vez). Antes disso responde 409. O conteúdo é privado —
        acesso somente pela URL assinada temporária (TTL 5 min).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemporaryUrl' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/documents/{id}/certificate:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    get:
      tags: [Documentos]
      summary: URL assinada temporária do certificado de auditoria
      description: >-
        Certificado de auditoria (PDF) — evidências, hashes original+final, QR
        de verificação pública e enquadramento jurídico (NÃO é PAdES/ICP-Brasil).
        Disponível apenas quando o documento está `signed`; antes disso 409.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemporaryUrl' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/documents/{id}/remind:
    parameters:
      - $ref: '#/components/parameters/DocumentId'
    post:
      tags: [Documentos]
      summary: Reenvia lembretes em massa aos signatários pendentes
      description: |
        Lembra de uma vez TODOS os signatários ainda pendentes do envelope,
        usando o **link vigente** (não invalida nem reemite o magic link —
        distinto do `resend`, que gera um novo link por signatário). Respeita o
        cooldown por envelope (`bulk_cooldown_hours`): signatários lembrados há
        menos que a janela são ignorados nesta chamada. Rate-limited por tenant.
        Sem corpo. A resposta traz quantos foram efetivamente lembrados.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      document_id: { type: string }
                      reminded:
                        type: integer
                        description: Quantidade de signatários efetivamente lembrados.
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ----------------------------------------------------------------- Templates
  /v1/document-templates:
    get:
      tags: [Templates]
      summary: Lista templates de documento do tenant (cursor)
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
        - name: active
          in: query
          required: false
          schema: { type: boolean }
          description: Filtra por `is_active` (ativos/inativos). Omitido → todos.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/DocumentTemplate' }
    post:
      tags: [Templates]
      summary: Cria um template de documento
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDocumentTemplateRequest' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplate' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/document-templates/{templateId}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    get:
      tags: [Templates]
      summary: Detalhe do template (inclui arquivos, papéis e campos)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplate' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Templates]
      summary: Atualiza o cabeçalho do template
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateDocumentTemplateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplate' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Templates]
      summary: Remove o template (soft delete)
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/document-templates/{templateId}/files:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    post:
      tags: [Templates]
      summary: Anexa um arquivo-base ao template
      description: |
        Upload do PDF-modelo (multipart/form-data, campo `file`). Passa pela
        mesma validação de upload do domínio (magic bytes + MIME + parse +
        rejeição de conteúdo ativo). O conteúdo vive em bucket S3 privado —
        acesso apenas via URL assinada temporária.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplateFile' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/document-templates/{templateId}/files/{fileId}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
      - name: fileId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Templates]
      summary: Remove um arquivo-base do template
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/document-templates/{templateId}/signers:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    post:
      tags: [Templates]
      summary: Cria um signatário-modelo (papel) no template
      description: |
        Papel abstrato (ex.: "Contratante") — NÃO carrega PII. Os dados reais
        do signatário são fornecidos na materialização (`assignments`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDocumentTemplateSignerRequest' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplateSigner' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/document-templates/{templateId}/signers/{signerId}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
      - name: signerId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Templates]
      summary: Remove um papel do template
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/document-templates/{templateId}/fields:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    post:
      tags: [Templates]
      summary: Cria um campo-modelo no template
      description: |
        Coordenadas na MESMA convenção do renderer da finalização:
        **milímetros absolutos da página**, origem no canto superior esquerdo
        (`x` → direita, `y` → baixo). O campo referencia um arquivo-base e,
        opcionalmente, um papel (`document_template_signer_id`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDocumentTemplateFieldRequest' }
      responses:
        '201':
          description: Criado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentTemplateField' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/document-templates/{templateId}/fields/{fieldId}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
      - name: fieldId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Templates]
      summary: Remove um campo do template
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/document-templates/{templateId}/documents:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    post:
      tags: [Templates]
      summary: Materializa o template em um documento draft
      description: |
        Cria um novo documento (envelope) em `draft` a partir do template —
        copia arquivos-base, campos e a estrutura de papéis, e vincula cada
        papel (`template_signer_id`) aos dados reais fornecidos em
        `assignments`. Todos os papéis do template precisam de um assignment
        correspondente (papel faltando → 422).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MaterializeTemplateRequest' }
      responses:
        '201':
          description: Documento draft criado a partir do template.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # ----------------------------------------------------------------- Verificação
  /v1/verify:
    post:
      tags: [Verificação]
      summary: Confere integridade por hash (público, rate-limited)
      description: |
        Upload de um arquivo + `code` → compara o SHA-256 (streaming) com os
        hashes registrados na fonte única (`document_hashes`, purposes
        `original` e `final_pdf` — bater com qualquer um = íntegro). O arquivo
        **nunca é armazenado**. `code` é obrigatório (busca só por hash é
        proibida — anti-scraping); code inexistente ou malformado retorna a
        mesma resposta `nao_encontrado` (200). Resultado nunca é "válido".
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, code]
              properties:
                file: { type: string, format: binary }
                code: { type: string, description: verification_code do documento (QR/URL do certificado) }
      responses:
        '200':
          description: Resultado da conferência (inclusive `nao_encontrado`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/VerifyResult' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/verify/{code}:
    get:
      tags: [Verificação]
      summary: Dados públicos da página de verificação (rate-limited)
      description: |
        Payload consumido pela página server-render `verificar/{codigo}`:
        status do documento, hashes esperados (original e final), data de
        conclusão, signatários (PII mascarada — cpf/telefone) e métodos de
        autenticação. Code inexistente → 200 com `found: false` (resposta
        uniforme, anti-enumeração). Leitura pura — não gera evento de trilha.
      security: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Dados públicos (ou `found:false`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/VerificationInfo' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/portal/login/request:
    post:
      tags: [Portal do signatário]
      summary: Solicita código de acesso ao portal (público, rate-limited)
      description: |
        Envia um OTP para o e-mail informado, se ele tiver histórico no AEDOC.
        Resposta SEMPRE genérica (anti-enumeração — nunca confirma existência).
        O código nunca trafega na resposta (só no e-mail); só o HMAC vive em
        repouso (R9/R14). Rate limit por IP e por e-mail.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Resposta genérica (não revela existência de histórico).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  data: { $ref: '#/components/schemas/PortalLoginRequested' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/portal/login/verify:
    post:
      tags: [Portal do signatário]
      summary: Troca código por uma sessão do portal (público, rate-limited)
      description: |
        Valida o OTP (constant-time, attempts/lock) e, em sucesso, emite um
        token de sessão de alta entropia (`portal_…`) — a capability do canal
        RLS-safe cross-tenant. O token é retornado UMA vez; o cliente o guarda e
        o envia como `Authorization: Bearer`. Falha genérica (código errado,
        expirado ou ausente indistinguíveis).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, maxLength: 12 }
      responses:
        '200':
          description: Sessão emitida.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PortalSession' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/portal/logout:
    post:
      tags: [Portal do signatário]
      summary: Encerra a sessão do portal
      description: Revoga a sessão corrente (idempotente).
      security: [{ portalSession: [] }]
      responses:
        '200':
          description: Sessão encerrada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/portal/documents:
    get:
      tags: [Portal do signatário]
      summary: Histórico do signatário (cross-tenant)
      description: |
        Lista todos os documentos em que o e-mail da sessão participou como
        signatário, através de TODOS os tenants emissores. PII mascarada (R27);
        ULID público (R20). Ordenado do mais recente.
      security: [{ portalSession: [] }]
      responses:
        '200':
          description: Histórico do signatário.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PortalDocumentListItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/portal/documents/{id}:
    get:
      tags: [Portal do signatário]
      summary: Detalhe de um documento do histórico
      description: |
        Detalhe de um documento em que o signatário da sessão participa. 404 se
        o e-mail não participa (indistinguível de inexistente — não vaza a
        existência de documentos de terceiros).
      security: [{ portalSession: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, description: ULID público }}
      responses:
        '200':
          description: Detalhe do documento.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PortalDocumentDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/portal/documents/{id}/download:
    get:
      tags: [Portal do signatário]
      summary: URL temporária do PDF final
      description: |
        URL temporária (TTL curto) do PDF final. 409 antes de o documento estar
        `signed` (o artefato final ainda não existe).
      security: [{ portalSession: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }}
      responses:
        '200':
          description: URL temporária do PDF final.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemporaryUrl' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/portal/documents/{id}/certificate:
    get:
      tags: [Portal do signatário]
      summary: URL temporária do certificado de auditoria
      description: URL temporária (TTL curto) do certificado. 409 antes de `signed`.
      security: [{ portalSession: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }}
      responses:
        '200':
          description: URL temporária do certificado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemporaryUrl' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/portal/documents/{id}/reopen:
    post:
      tags: [Portal do signatário]
      summary: Gera novo link para reabrir um pendente
      description: |
        Emite um NOVO magic link para o próprio e-mail da sessão reabrir um
        documento pendente (o vigente é irrecuperável — R6 — então emitir em
        paralelo é o padrão do lembrete; NUNCA invalida o vigente). 409 se o
        documento não está aguardando a assinatura deste signatário.
      security: [{ portalSession: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }}
      responses:
        '200':
          description: Novo link de assinatura.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PortalReopenLink' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /verificar/{codigo}:
    get:
      tags: [Verificação]
      summary: Página pública de verificação (server-render, HTML)
      description: Página HTML de conferência por código. Fora do prefixo /v1.
      security: []
      parameters:
        - { name: codigo, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Página HTML de verificação.
          content:
            text/html:
              schema: { type: string }
        '404': { description: Código não encontrado }

  # ----------------------------------------------------------- Chaves de API
  /v1/api-keys:
    get:
      tags: [Chaves de API]
      summary: Lista as chaves do tenant (cursor) — nunca expõe o segredo
      security:
        - cookieAuth: []
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Chaves de API]
      summary: Cria uma chave — o segredo aparece SOMENTE nesta resposta
      description: |
        O campo `key` (ex.: `aedoc_live_…`) é exibido UMA única vez; em repouso
        vive apenas o hash SHA-256 (R9). Guarde-o em um cofre de segredos.
        Gestão exclusiva da sessão do painel (uma chave não cria/revoga chaves).
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyInput' }
      responses:
        '201':
          description: Criada — `key` presente apenas aqui.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ApiKey' }
                  key:
                    type: string
                    description: Segredo completo — exibido uma única vez.
                    example: aedoc_live_9f2c4a1b7e5d3f8a0c6b2d4e8f1a3c5b7d9e0f2a4c6b8d0e
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/api-keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Chaves de API]
      summary: Revoga a chave (soft, irreversível — crie outra para substituir)
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Revogada (a linha permanece como evidência de uso).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # -------------------------------------------------------------- Membros
  /v1/members:
    get:
      tags: [Membros]
      summary: Membros ativos + convites pendentes + uso de assentos
      description: Exige a permissão `members.view` (admin). Exclusiva da sessão.
      security:
        - cookieAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items: { $ref: '#/components/schemas/Member' }
                  invitations:
                    type: array
                    items: { $ref: '#/components/schemas/Invitation' }
                  seats: { $ref: '#/components/schemas/SeatUsage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/members/invitations:
    post:
      tags: [Membros]
      summary: Convida um e-mail com um papel (exige members.manage)
      description: Gate de assentos por plano (422 se estourado). Supersede convite pendente ao mesmo e-mail.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvitationInput' }
      responses:
        '201':
          description: Convite criado (e-mail enfileirado).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invitation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/members/invitations/{invitation}:
    parameters:
      - { name: invitation, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Membros]
      summary: Revoga um convite pendente (exige members.manage)
      security:
        - cookieAuth: []
      responses:
        '204': { description: Revogado. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/members/invitations/{invitation}/resend:
    parameters:
      - { name: invitation, in: path, required: true, schema: { type: string } }
    post:
      tags: [Membros]
      summary: Reenvia um convite pendente (novo token; exige members.manage)
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Reenviado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invitation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/members/{member}:
    parameters:
      - { name: member, in: path, required: true, schema: { type: integer } }
    patch:
      tags: [Membros]
      summary: Altera o papel de um membro ativo (exige members.manage)
      description: Bloqueia rebaixar o último admin (422).
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { $ref: '#/components/schemas/TenantRole' }
      responses:
        '200':
          description: Papel alterado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Member' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Membros]
      summary: Revoga o acesso de um membro (exige members.manage)
      description: Bloqueia remover o último admin (422). Não apaga o usuário (identidade global).
      security:
        - cookieAuth: []
      responses:
        '204': { description: Acesso revogado. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # -------------------------------------------------------------- Convites (aceite público)
  /v1/invitations/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      tags: [Convites]
      summary: Dados para renderizar a página de aceite (link assinado)
      description: Rota pública (sem sessão). Link assinado (HMAC + TTL). Não exige `securitySchemes`.
      responses:
        '200':
          description: Convite válido.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvitationPublic' }
        '410':
          description: Convite não encontrado, expirado ou já utilizado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/invitations/{token}/accept:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      tags: [Convites]
      summary: Aceita o convite (cria/associa a conta; sem login automático)
      description: Rota pública. Nome/senha exigidos apenas quando o e-mail ainda não tem conta.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AcceptInvitationInput' }
      responses:
        '200':
          description: Convite aceito — faça login.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  email: { type: string }
                  tenant_name: { type: string }
        '410':
          description: Convite inválido/expirado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # -------------------------------------------------------------- Relatórios
  /v1/reports/documents:
    get:
      tags: [Relatórios]
      summary: Exporta a lista de documentos do período (CSV ou PDF)
      description: Exige `reports.export`. Mesmos filtros da listagem. PII mascarada, sem conteúdo.
      security:
        - cookieAuth: []
      parameters:
        - { name: format, in: query, required: true, schema: { type: string, enum: [csv, pdf] } }
        - { name: status, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: Arquivo (attachment).
          content:
            text/csv:
              schema: { type: string, format: binary }
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/reports/usage:
    get:
      tags: [Relatórios]
      summary: Exporta o relatório de uso/consumo do período (CSV ou PDF)
      description: Exige `reports.export`. Agregado do usage_ledger por métrica (envios, OTP, lembretes).
      security:
        - cookieAuth: []
      parameters:
        - { name: format, in: query, required: true, schema: { type: string, enum: [csv, pdf] } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: Arquivo (attachment).
          content:
            text/csv:
              schema: { type: string, format: binary }
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # -------------------------------------------------------------- Webhooks
  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: Lista webhooks do tenant (cursor) — escopo webhooks:read
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Webhooks]
      summary: Cria um webhook — o segredo HMAC aparece SOMENTE nesta resposta
      description: |
        `secret` (`whsec_…`) sai uma única vez (em repouso vive cifrado — R9);
        use-o para verificar `X-AEDOC-Signature`. A URL deve ser https e
        pública (anti-SSRF). Escopo: webhooks:write. NÃO aceita
        Idempotency-Key: a resposta carrega o segredo e não pode ser
        memoizada/replayada.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookInput' }
      responses:
        '201':
          description: Criado — `secret` presente apenas aqui.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
                  secret:
                    type: string
                    description: Segredo HMAC — exibido uma única vez.
                    example: whsec_1f3e5d7c9b1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Webhooks]
      summary: Detalhe do webhook — escopo webhooks:read
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Webhooks]
      summary: Atualiza url/eventos e liga/desliga — escopo webhooks:write
      description: |
        `active: true` reativa um endpoint pausado (manual ou auto-disable) e
        zera a régua de falha contínua; `active: false` pausa sem perder a
        configuração nem o histórico.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookUpdate' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Webhooks]
      summary: Remove (soft) — o histórico de entregas permanece
      responses:
        '204': { description: Removido }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/webhooks/{id}/rotate-secret:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Webhooks]
      summary: Rotaciona o segredo HMAC (janela dupla) — escopo webhooks:write
      description: |
        O novo segredo sai SOMENTE nesta resposta. Durante a janela de rotação
        (24h) as entregas levam DOIS `v1=` no header `X-AEDOC-Signature` —
        assinados com o segredo novo e com o anterior — para o consumidor
        migrar sem perder entregas.
      responses:
        '200':
          description: Rotacionado — `secret` novo presente apenas aqui.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
                  secret:
                    type: string
                    description: Novo segredo HMAC — exibido uma única vez.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/webhooks/{id}/test:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Webhooks]
      summary: Dispara uma entrega de teste (`webhook.test`) — escopo webhooks:write
      description: |
        Percorre o pipeline REAL (fila, HMAC sobre o raw body, histórico) com
        um evento sintético `webhook.test`. 202 — o resultado aparece no
        histórico de entregas.
      responses:
        '202':
          description: Enfileirada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookDelivery' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/webhooks/{id}/deliveries:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Webhooks]
      summary: Histórico de entregas do webhook (cursor) — escopo webhooks:read
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/webhook-deliveries/{id}/retry:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Webhooks]
      summary: Reenvia manualmente uma entrega failed/exhausted — escopo webhooks:write
      description: |
        Volta a entrega para `pending` e redespacha com um ciclo novo de
        tentativas. `pending`/`delivered` → 422 (nada a reenviar).
      responses:
        '202':
          description: Reenfileirada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookDelivery' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # ----------------------------------------------------------------- Cobrança
  # Superfície de SESSÃO apenas (chave Bearer não gerencia cobrança). Dados de
  # cartão NUNCA passam pelo AEDOC: checkout e portal são hospedados no Stripe.
  /v1/billing:
    get:
      tags: [Cobrança]
      summary: Plano, uso e estado de cobrança do tenant
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: Plano/uso do mês + estado da assinatura + planos contratáveis.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BillingOverview' }

  /v1/billing/checkout:
    post:
      tags: [Cobrança]
      summary: Inicia o upgrade — URL de checkout hospedado (assinatura)
      description: |
        Cria a Checkout Session no provedor e devolve a URL para redirecionar
        o admin. A ativação do plano acontece via webhook do provedor (nunca
        confie no redirect de sucesso). Requer 2FA de admin.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_code]
              properties:
                plan_code: { type: string, example: professional, description: Plano pago com Price configurado }
      responses:
        '200':
          description: URL do checkout hospedado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, format: uri }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/billing/portal:
    post:
      tags: [Cobrança]
      summary: Portal do assinante (trocar cartão, faturas, cancelar)
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: URL do Customer Portal hospedado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, format: uri }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/billing/wallet:
    get:
      tags: [Cobrança]
      summary: Carteira pré-paga — saldo, teto e configuração (sessão)
      description: |
        Snapshot da carteira do tenant. 404
        `wallet_unavailable` quando o plano não possui carteira
        (Free/legado/Enterprise) — Free nunca cobra por ação.
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: Snapshot da carteira.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WalletSummary' }
        '404': { description: Plano sem carteira (`code=wallet_unavailable`). }

  /v1/billing/wallet/transactions:
    get:
      tags: [Cobrança]
      summary: Extrato da carteira (paginado, mais recente primeiro)
      security: [{ cookieAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
      responses:
        '200':
          description: Lançamentos do ledger da carteira.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WalletTransaction' }
                  meta:
                    type: object
                    properties:
                      current_page: { type: integer }
                      last_page: { type: integer }
                      total: { type: integer }
        '404': { description: Plano sem carteira (`code=wallet_unavailable`). }

  /v1/billing/wallet/topup:
    post:
      tags: [Cobrança]
      summary: Recarga manual — URL de checkout hospedado do pacote
      description: |
        Pacotes fixos (config). Cartão nunca toca o AEDOC (SAQ-A); o crédito
        acontece via webhook do provedor, idempotente por payment_intent.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [package_cents]
              properties:
                package_cents: { type: integer, example: 3000, description: 'Um dos pacotes: 3000/6000/15000/30000' }
      responses:
        '200':
          description: URL do checkout hospedado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, format: uri }
        '404': { description: Plano sem carteira (`code=wallet_unavailable`). }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/billing/wallet/auto-recharge:
    put:
      tags: [Cobrança]
      summary: Configura a recarga automática (saldo < X → recarrega Y, teto mensal Z)
      description: |
        Opt-in explícito do admin (2FA). Z é OBRIGATÓRIO quando ligado —
        anti-loop/anti-disputa. A ativação exige cartão utilizável na
        assinatura (a cobrança é off-session); 3 recusas consecutivas
        desligam automaticamente e avisam o admin por e-mail.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
                threshold_cents: { type: [integer, 'null'], example: 1000, description: X — obrigatório quando enabled }
                amount_cents: { type: [integer, 'null'], example: 3000, description: 'Y — um dos pacotes (3000/6000/15000/30000); obrigatório quando enabled' }
                monthly_cap_cents: { type: [integer, 'null'], example: 9000, description: 'Z — obrigatório quando enabled; ≥ amount_cents' }
      responses:
        '200':
          description: Snapshot atualizado da carteira.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WalletSummary' }
        '404': { description: Plano sem carteira (`code=wallet_unavailable`). }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/wallet:
    get:
      tags: [Cobrança]
      summary: Saldo da carteira — leitura por máquina (Bearer, escopo billing:read)
      description: |
        Único endpoint de carteira na superfície Bearer, SÓ leitura —
        integrador que paga por ação monitora o saldo programaticamente.
        Toda escrita (recarga, auto-recarga) é exclusiva de sessão.
      security: [{ bearerApiKey: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Snapshot da carteira.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WalletSummary' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { description: Plano sem carteira (`code=wallet_unavailable`). }

  /v1/billing/webhook/stripe:
    post:
      tags: [Cobrança]
      summary: Webhook do provedor de cobrança (público, assinado)
      description: |
        Consumido pelo STRIPE, não por integradores. Autorização = assinatura
        HMAC sobre o corpo cru (`Stripe-Signature: t=…,v1=…`), verificada
        antes de qualquer parse (R25). Idempotente pelo id do evento
        (replay/retry processa uma vez). Sem segredo configurado → 503.
      security: []
      responses:
        '200': { description: Evento aceito (processado ou replay ignorado). }
        '400': { description: Assinatura inválida. }
        '503': { description: Cobrança não configurada neste ambiente. }

  # ----------------------------------------------------------------- Backoffice
  # Operador da PLATAFORMA (users.is_platform_admin — promoção só via artisan)
  # com 2FA obrigatório para TUDO, leitura inclusa. Leitura cross-tenant via
  # policies RLS dedicadas (FOR SELECT — nunca BYPASSRLS); escrita via runAs
  # no tenant alvo, auditada na chain DO tenant.
  /v1/admin/tenants:
    get:
      tags: [Backoffice]
      summary: Lista tenants com plano ativo e uso do período
      security: [{ cookieAuth: [] }]
      parameters:
        - name: period
          in: query
          required: false
          schema: { type: string, format: date, example: '2026-07-01' }
          description: 1º dia do mês de competência (default = mês corrente).
      responses:
        '200':
          description: Tenants + plano + uso (cross-tenant).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AdminTenant' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/admin/tenants/{id}/plan:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      tags: [Backoffice]
      summary: Troca manual de plano (cortesia de piloto, Enterprise negociado)
      description: |
        Encerra a assinatura vigente e abre uma nova SEM vínculo de provedor —
        a cobrança automática nunca gerencia assinaturas manuais. Auditado
        como `plan_changed` (source=backoffice) na chain do tenant.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_code]
              properties:
                plan_code: { type: string, example: starter }
      responses:
        '200':
          description: Plano aplicado (idempotente se já estiver no plano).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tenant_id: { type: string }
                      plan_code: { type: string }
                      subscription_status: { type: string }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /v1/admin/tenants/{id}/suspend:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Backoffice]
      summary: Suspende o tenant (painel/API bloqueados; envelopes em curso seguem assináveis)
      security: [{ cookieAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Suspenso (idempotente). Rotas do tenant passam a responder 403 `tenant_suspended`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, enum: [suspended] }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/admin/tenants/{id}/reactivate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Backoffice]
      summary: Reativa um tenant suspenso
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: Reativado (idempotente).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, enum: [active] }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/admin/plans:
    get:
      tags: [Backoffice]
      summary: Catálogo de planos
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: Planos (ativos e inativos).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AdminPlan' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/admin/webhook-deliveries:
    get:
      tags: [Backoffice]
      summary: Entregas de webhook cross-tenant (suporte operacional)
      security: [{ cookieAuth: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [pending, delivered, failed, exhausted] } }
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - { name: per_page, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200':
          description: Entregas paginadas (payloads THIN — sem PII, R28).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AdminWebhookDelivery' }
                  meta:
                    type: object
                    properties:
                      current_page: { type: integer }
                      last_page: { type: integer }
                      total: { type: integer }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/admin/integrity:
    get:
      tags: [Backoffice]
      summary: Visão de integridade cross-tenant (âncoras + falhas recentes)
      description: |
        Últimas âncoras de head por tenant/stream (aedoc:integridade:verificar)
        e eventos `integrity_check_failed` recentes. A policy de leitura em
        audit_events é RESTRITA a esse event_type (minimização R27).
      security: [{ cookieAuth: [] }]
      responses:
        '200':
          description: Âncoras + falhas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      anchors:
                        type: array
                        items:
                          type: object
                          properties:
                            tenant_id: { type: string }
                            stream: { type: string }
                            last_sequence: { type: integer }
                            anchored_at: { type: string, format: date-time }
                      failures:
                        type: array
                        items:
                          type: object
                          properties:
                            tenant_id: { type: string }
                            document_id: { type: [string, 'null'] }
                            occurred_at: { type: string, format: date-time }
                            metadata: { type: object }
        '403': { $ref: '#/components/responses/Forbidden' }

# ------------------------------------------------------------------- Webhooks (eventos publicados)
webhooks:
  document.created:   { $ref: '#/components/pathItems/documentEvent' }
  document.uploaded:  { $ref: '#/components/pathItems/documentEvent' }
  document.sent:      { $ref: '#/components/pathItems/documentEvent' }
  document.viewed:    { $ref: '#/components/pathItems/documentEvent' }
  signer.authenticated: { $ref: '#/components/pathItems/documentEvent' }
  document.partially_signed: { $ref: '#/components/pathItems/documentEvent' }
  document.signed:    { $ref: '#/components/pathItems/documentEvent' }
  document.declined:  { $ref: '#/components/pathItems/documentEvent' }
  document.expired:   { $ref: '#/components/pathItems/documentEvent' }
  document.cancelled: { $ref: '#/components/pathItems/documentEvent' }
  document.failed:    { $ref: '#/components/pathItems/documentEvent' }

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: aedoc_session
      description: |
        Sanctum stateful. Fluxo: `GET /sanctum/csrf-cookie` → `POST /v1/auth/login`.
        Requisições mutadoras enviam também o header `X-XSRF-TOKEN`.
    bearerApiKey:
      type: http
      scheme: bearer
      description: |
        Chave de API por tenant: `Authorization: Bearer
        aedoc_live_…` (ou `aedoc_test_…` — hoje apenas rótulo; não há sandbox
        dedicado). Tabela `api_keys` própria (não reutiliza
        personal_access_tokens), escopos por chave, rate limit POR PLANO com
        bucket POR TENANT — Free 10 · Professional 60 · Enterprise 200 req/min
        (429 com `code: rate_limited` ao estourar; todas as chaves do tenant
        dividem o teto), revogação soft. O segredo é exibido UMA vez na
        criação e vive apenas como hash SHA-256 em repouso. A gestão
        (/v1/api-keys) é exclusiva da sessão do painel.
    portalSession:
      type: http
      scheme: bearer
      description: |
        Sessão do PORTAL DO SIGNATÁRIO: `Authorization: Bearer
        portal_…`. Token de 256 bits emitido após validação de OTP por e-mail
        (sem conta de tenant); é a capability do canal RLS-safe cross-tenant.
        Só o hash SHA-256 vive em repouso; TTL curto; revogável por logout.

  parameters:
    Cursor:
      name: cursor
      in: query
      required: false
      schema: { type: string }
      description: Cursor de paginação (opaco).
    PerPage:
      name: per_page
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    DocumentId:
      name: id
      in: path
      required: true
      schema: { type: string, example: 01kwf0abcdefghijklmnopqrst }
    SignerId:
      name: signer_id
      in: path
      required: true
      schema: { type: string }
    FieldId:
      name: field_id
      in: path
      required: true
      schema: { type: string }
    TemplateId:
      name: templateId
      in: path
      required: true
      schema: { type: string, example: 01kwf0abcdefghijklmnopqrst }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: |
        Idempotência inbound (opt-in). Repetir a MESMA chave em até 24h devolve
        a resposta original com o header `Idempotent-Replayed: true`, sem
        executar de novo — retry de rede seguro. Em `/send` também funciona sem
        o header (anti duplo-clique com discriminador natural da URL).

  responses:
    Message:
      description: Mensagem
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
    Unauthorized:
      description: Não autenticado
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Sem permissão / e-mail não verificado
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Não encontrado (inclui bloqueio cross-tenant por RLS)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: "Conflito (ex.: já enviado / requisição idêntica em processamento)"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Gone:
      description: "Recurso expirado ou consumido (ex.: magic link já utilizado)"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: >
        Rate limit excedido. No caminho Bearer o teto é o do plano (bucket por
        tenant) e o corpo traz `code: rate_limited`; respeite `Retry-After`.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
              code: { type: string, enum: [rate_limited] }
    ValidationError:
      description: Erro de validação
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }

  pathItems:
    documentEvent:
      post:
        summary: Evento de ciclo de vida do documento (HMAC sobre o body cru)
        description: |
          Entrega assinada com `X-AEDOC-Signature: t=<unix>,v1=<hmac_sha256(secret, "t." + raw_body)>`
          — HMAC-SHA256 sobre o **body cru recebido**, nunca JSON reserializado.
          Durante a rotação de segredo o header traz DOIS `v1=` (aceite se
          qualquer um conferir). Rejeite `t` fora de ±5 min (anti-replay).
          Headers adicionais: `X-AEDOC-Event` (nome) e `X-AEDOC-Event-Id`.
          Deduplique pelo `event_id` (estável em toda reentrega). Responda 2xx
          em <10s; retry com backoff exponencial (~8 tentativas/24h) →
          `exhausted`; endpoint falhando por ~7 dias é auto-desativado.
        requestBody:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
        responses:
          '200': { description: Recebido }

  schemas:
    SigningLink:
      description: >-
        Magic link do signatário, para o INTEGRADOR entregar pelo canal dele
        (app próprio, QR code). Retornado UMA ÚNICA VEZ: só o SHA-256 do token
        é persistido, então a URL não é recuperável depois — guarde-a. Emitir
        de novo gera um link ADICIONAL: este aqui continua valendo até expirar
        (para revogar, use `resend`).
      type: object
      properties:
        url:
          type: string
          format: uri
          description: 'Página pública de assinatura ({app_url}/assinar/{token}) — é o link que o signatário abre'
          example: 'https://app.aedoc.com.br/assinar/9f8a1c2d?tenant=01J0&expires=1760000000&signature=abc123'
        expires_at:
          type: string
          format: date-time
          description: 'Menor entre o TTL do magic link e a expiração do envelope'
        signer_id: { type: string, description: ULID do signatário }
        delivery: { type: string, enum: [integrator], description: 'A AEDOC não notificou ninguém: a entrega é responsabilidade do integrador' }

    TemporaryUrl:
      description: >-
        URL assinada temporária — único caminho de acesso a conteúdo privado
        (PDF original/final, certificado). Expira em `expires_in` segundos.
      type: object
      properties:
        data:
          type: object
          properties:
            url: { type: string, format: uri }
            expires_in: { type: integer, example: 300 }

    PortalLoginRequested:
      description: Metadados de UX do request de OTP (sem vazar existência).
      type: object
      properties:
        sent_to_masked: { type: string, example: 'j***@example.com' }
        cooldown_seconds: { type: integer, example: 60 }
        expires_in_minutes: { type: integer, example: 10 }

    PortalSession:
      description: Token de sessão do portal (retornado uma vez) + expiração.
      type: object
      properties:
        token: { type: string, example: 'portal_9f8a…', description: 'Bearer da sessão — só aqui em claro' }
        expires_at: { type: string, format: date-time }

    PortalDocumentListItem:
      description: Item do histórico do signatário (cross-tenant). PII mascarada.
      type: object
      properties:
        id: { type: string, description: ULID público }
        title: { type: string }
        status: { type: string, description: 'valor do enum DocumentStatus (nunca "válido")' }
        signing_type: { type: string, enum: [parallel, sequential] }
        tenant_name: { type: string, description: 'empresa emissora' }
        is_expired: { type: boolean }
        sent_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        can_download: { type: boolean }
        my_status: { type: string, description: 'status do próprio signatário' }
        my_sign_order: { type: integer }
        my_via_api: { type: boolean, description: 'linha de signatário-emissor (auth_method=api): assina server-side — não há link para reabrir nem ação pendente do titular' }

    PortalDocumentDetail:
      description: Detalhe de um documento do histórico. Só a PII do próprio signatário (mascarada) e contagem dos demais.
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        status: { type: string }
        signing_type: { type: string }
        tenant_name: { type: string, nullable: true }
        is_expired: { type: boolean }
        sent_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        can_download: { type: boolean }
        me:
          type: object
          nullable: true
          properties:
            name: { type: string }
            email_masked: { type: string }
            status: { type: string }
            via_api: { type: boolean, description: 'signatário-emissor (auth_method=api): assina server-side, nunca por link' }
            sign_order: { type: integer }
            signed_at: { type: string, format: date-time, nullable: true }
            declined_at: { type: string, format: date-time, nullable: true }
        signers_total: { type: integer }
        signers_signed: { type: integer }

    PortalReopenLink:
      description: Novo magic link para reabrir um pendente.
      type: object
      properties:
        url: { type: string, format: uri }
        expires_at: { type: string, format: date-time }

    RegisterRequest:
      type: object
      required: [company_name, name, email, password, password_confirmation]
      properties:
        company_name: { type: string, example: Acme Ltda }
        name: { type: string, example: João Silva }
        email: { type: string, format: email }
        password: { type: string, format: password, minLength: 8 }
        password_confirmation: { type: string, format: password }

    ContactRequest:
      type: object
      required: [name, email, message]
      properties:
        name: { type: string, maxLength: 120, example: Maria Souza }
        email: { type: string, format: email }
        subject: { type: [string, 'null'], maxLength: 150 }
        message: { type: string, minLength: 10, maxLength: 5000 }
        turnstile_token:
          type: [string, 'null']
          maxLength: 2048
          description: Token do Cloudflare Turnstile — exigido quando o captcha está habilitado no servidor.
        website:
          type: [string, 'null']
          maxLength: 255
          description: Campo-isca (honeypot). Oculto no front; deixe vazio. Se preenchido, a mensagem é descartada silenciosamente.

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
        remember: { type: boolean, default: false }
        code:
          type: [string, 'null']
          maxLength: 12
          description: TOTP do app autenticador — obrigatório quando a conta tem 2FA confirmado.
        recovery_code:
          type: [string, 'null']
          maxLength: 32
          description: Alternativa ao TOTP — código de recuperação (single-use).

    User:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string, format: email }
        email_verified: { type: boolean }
        locale: { type: string, example: pt_BR }
        timezone: { type: string, example: America/Sao_Paulo }
        is_platform_admin:
          type: boolean
          description: |
            Operador do backoffice da plataforma. O painel usa só
            para EXIBIR o link /painel/admin — a autorização real é do
            middleware platform.admin (403 sem o papel + 2FA).
        current_tenant_id: { type: [string, 'null'] }
        current_tenant: { $ref: '#/components/schemas/Tenant' }
        two_factor:
          type: object
          description: Estado do 2FA da conta. O secret nunca é exposto.
          properties:
            enabled: { type: boolean, description: Secret gerado (pendente ou confirmado) }
            confirmed: { type: boolean, description: TOTP provado — 2FA ativo de fato }
        memberships:
          type: array
          items: { $ref: '#/components/schemas/Membership' }
        created_at: { type: string, format: date-time }

    Tenant:
      type: object
      properties:
        id: { type: string, example: 01kweypxpsvskddwmsgn905exz }
        name: { type: string }
        slug: { type: string }
        legal_name: { type: [string, 'null'] }
        status: { type: string, enum: [active, suspended, cancelled] }
        created_at: { type: string, format: date-time }

    Membership:
      type: object
      properties:
        tenant_id: { type: string }
        role: { type: string, enum: [admin, member] }
        status: { type: string, enum: [active, invited, suspended] }
        joined_at: { type: [string, 'null'], format: date-time }
        tenant: { $ref: '#/components/schemas/Tenant' }

    Plan:
      type: object
      properties:
        id: { type: integer }
        code: { type: string, enum: [free, professional, enterprise, starter, pro, business], description: 'starter/pro/business são LEGADO (fora de venda; assinantes preservados)' }
        name: { type: string }
        price_cents: { type: integer }
        currency: { type: string, example: BRL }
        monthly_document_limit: { type: [integer, 'null'] }
        seat_limit: { type: [integer, 'null'], description: 'Assentos (usuários) do plano; null = ilimitado' }

    TenantRole:
      type: string
      description: Papel do usuário dentro do tenant.
      enum: [admin, member, financeiro, operador]

    Member:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        role: { $ref: '#/components/schemas/TenantRole' }
        role_label: { type: string }
        status: { type: string }
        two_factor_enabled: { type: boolean }
        joined_at: { type: [string, 'null'], format: date-time }

    Invitation:
      type: object
      properties:
        id: { type: string }
        email: { type: string }
        role: { $ref: '#/components/schemas/TenantRole' }
        role_label: { type: string }
        status: { type: string, enum: [pending, accepted, revoked] }
        expires_at: { type: string, format: date-time }
        created_at: { type: [string, 'null'], format: date-time }

    InvitationInput:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        role: { $ref: '#/components/schemas/TenantRole' }

    InvitationPublic:
      type: object
      properties:
        email: { type: string }
        role: { $ref: '#/components/schemas/TenantRole' }
        tenant_name: { type: [string, 'null'] }
        requires_account: { type: boolean, description: 'true = e-mail sem conta; pede nome+senha' }
        expires_at: { type: string, format: date-time }

    AcceptInvitationInput:
      type: object
      properties:
        name: { type: string }
        password: { type: string, format: password }
        password_confirmation: { type: string, format: password }

    SeatUsage:
      type: object
      properties:
        limit: { type: [integer, 'null'] }
        used: { type: integer }
        remaining: { type: [integer, 'null'] }

    Subscription:
      type: object
      properties:
        id: { type: integer }
        tenant_id: { type: string }
        status: { type: string, enum: [active, trialing, past_due, canceled] }
        current_period_start: { type: [string, 'null'], format: date-time }
        current_period_end: { type: [string, 'null'], format: date-time }
        plan: { $ref: '#/components/schemas/Plan' }

    DocumentStatus:
      type: string
      description: Ciclo de vida do documento (§7). `processing` é obrigatório antes de `signed`.
      enum: [draft, ready, sent, viewed, partially_signed, processing, signed, declined, expired, cancelled, failed]

    SignatureLevel:
      type: string
      description: Nível de assinatura. `qualificada` ainda não é oferecida — retorna 422.
      enum: [simples, avancada, qualificada]
      default: avancada

    AuthMethod:
      type: string
      description: |
        Método de autenticação do signatário. Ativos: `email`/`otp_email`
        (e `otp_sms`/`whatsapp` quando habilitados para o tenant —
        exigem `phone` do signatário na criação); demais → 422.

        `api` é o signatário-EMISSOR: assina server-side com a
        credencial autenticada do tenant no `/send` (pré-assinatura) ou na
        cascata sequencial — sem magic link/OTP. Configurá-lo via chave de API
        exige o escopo `documents:sign` (403 `insufficient_scope` sem ele).
      enum: [email, otp_email, otp_sms, whatsapp, pix, icp, api]

    Document:
      type: object
      properties:
        id: { type: string, example: 01kwf0abcdefghijklmnopqrst }
        tenant_id: { type: string }
        title: { type: string }
        status: { $ref: '#/components/schemas/DocumentStatus' }
        signature_level: { $ref: '#/components/schemas/SignatureLevel' }
        signing_type: { type: string, enum: [sequential, parallel] }
        verification_code: { type: [string, 'null'] }
        original_sha256: { type: [string, 'null'] }
        final_sha256: { type: [string, 'null'] }
        expires_at: { type: [string, 'null'], format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        files:
          type: array
          items: { $ref: '#/components/schemas/DocumentFile' }
        signers:
          type: array
          items: { $ref: '#/components/schemas/Signer' }

    CreateDocumentRequest:
      type: object
      required: [title]
      properties:
        title: { type: string, example: Contrato de Prestação de Serviços }
        signature_level: { $ref: '#/components/schemas/SignatureLevel' }
        signing_type: { type: string, enum: [sequential, parallel], default: sequential }
        expires_at: { type: string, format: date-time }
        signers:
          type: array
          items: { $ref: '#/components/schemas/SignerInput' }

    DocumentFile:
      type: object
      properties:
        id: { type: string }
        document_id: { type: string }
        filename: { type: string }
        byte_size: { type: integer }
        sha256: { type: string }
        page_count: { type: [integer, 'null'] }
        created_at: { type: string, format: date-time }

    SignerInput:
      type: object
      required: [name]
      properties:
        name: { type: string, example: João Silva }
        email:
          type: [string, 'null']
          format: email
          description: |
            E-mail e/ou telefone — pelo menos um (422 sem ambos). Presente,
            é o canal do convite (magic link) e a identidade do portal do
            signatário. Ausente (signatário só-WhatsApp), o convite E o OTP
            saem por WhatsApp — exige telefone BR válido, convite por WhatsApp
            habilitado na conta e `auth_method` de canal externo (default
            automático: `whatsapp`); a régua de lembretes automáticos não
            cobre signatários sem e-mail (reenvie por
            `POST .../signers/{id}/resend`).
        phone:
          type: [string, 'null']
          example: '+5599999999999'
          description: |
            Telefone BR com DDD. Obrigatório quando `auth_method` usa canal
            externo (otp_sms/whatsapp) e quando o signatário não tem e-mail.
            Com o convite por WhatsApp ativo no tenant, signatário com
            telefone E e-mail recebe o convite pelos DOIS canais (o WhatsApp
            reforça; não há campo de canal — os contatos informados definem
            a entrega).
        cpf: { type: [string, 'null'], description: Opcional (minimização LGPD) }
        auth_method: { $ref: '#/components/schemas/AuthMethod' }
        sign_order:
          type: integer
          minimum: 1
          description: |
            Posição na ordem de assinatura. Omitido → próxima posição (N+1).
            Explícito precisa manter a ordem contígua 1..N sem duplicata
            (422 senão). Para reordenar, use PATCH no signatário.

    SignerUpdate:
      type: object
      description: |
        Atualização parcial de signatário (draft/ready). Mudança de
        sign_order tem semântica de MOVER (desloca os intermediários; a ordem
        segue contígua 1..N; fora da faixa → 422).
      properties:
        name: { type: string }
        email:
          type: [string, 'null']
          format: email
          description: |
            `null` remove o e-mail — legítimo se o telefone sustenta o convite
            (signatário só-WhatsApp): o estado FINAL precisa de e-mail e/ou
            telefone, e sem e-mail o `auth_method` precisa ser de canal
            externo (422 senão).
        phone:
          type: [string, 'null']
          description: |
            Telefone BR com DDD (ver `SignerInput.phone` — os contatos do
            estado final definem a entrega: e-mail+telefone = convite pelos
            dois canais; só telefone = convite e OTP por WhatsApp).
        cpf: { type: [string, 'null'] }
        auth_method: { $ref: '#/components/schemas/AuthMethod' }
        sign_order: { type: integer, minimum: 1 }

    Signer:
      type: object
      properties:
        id: { type: string }
        document_id: { type: string }
        name: { type: string }
        email: { type: [string, 'null'], format: email, description: 'null = signatário só-WhatsApp (convite e OTP pelo telefone)' }
        phone_masked: { type: [string, 'null'], description: Telefone mascarado (LGPD) }
        cpf_masked: { type: [string, 'null'], description: CPF mascarado (LGPD) }
        status: { type: string, enum: [pending, sent, viewed, signed, declined] }
        auth_method: { $ref: '#/components/schemas/AuthMethod' }
        sign_order: { type: integer }
        viewed_at: { type: [string, 'null'], format: date-time }
        signed_at: { type: [string, 'null'], format: date-time }
        declined_at: { type: [string, 'null'], format: date-time }

    Signature:
      type: object
      description: |
        Artefato probatório do aceite. Nunca expõe o caminho privado
        da imagem (acesso só via temporaryUrl) nem ip/user_agent crus.
      properties:
        id: { type: string }
        document_id: { type: string }
        signer_id: { type: string }
        # `server` = assinatura server-side do emissor; os demais são
        # capturas do navegador. O request de aceite público (abaixo) NUNCA
        # aceita `server` — só o /send/cascata a produz.
        signature_source: { type: string, enum: [drawn, typed, uploaded, server] }
        consent_version: { type: string }
        consent_hash: { type: string, description: sha256 do texto exato do consentimento }
        signature_hash: { type: string, description: Fingerprint sha256 recomputável do aceite }
        signed_at: { type: string, format: date-time }

    AcceptSignatureRequest:
      type: object
      required: [consent_accepted, consent_version, signature_source, signature_image]
      properties:
        consent_accepted:
          type: boolean
          description: Checkbox "Li e concordo em assinar eletronicamente este documento." (obrigatório).
        consent_version:
          type: string
          description: Versão do consentimento EXIBIDA; versão defasada → 422 (recarregar a página).
        signature_source:
          type: string
          enum: [drawn, typed, uploaded]
        signature_image:
          type: string
          description: Captura como data URI base64 (image/png ou image/jpeg), máx. 1 MB binário.
          example: 'data:image/png;base64,iVBORw0KGgo…'

    SignerAcceptance:
      type: object
      description: Payload completo da página pública de aceite.
      properties:
        signer: { $ref: '#/components/schemas/Signer' }
        document:
          type: object
          properties:
            id: { type: string }
            title: { type: string }
            status: { $ref: '#/components/schemas/DocumentStatus' }
            signature_level: { $ref: '#/components/schemas/SignatureLevel' }
            sender_name: { type: string, description: Empresa remetente (tenant) }
            expires_at: { type: [string, 'null'], format: date-time }
        files:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              filename: { type: string }
              byte_size: { type: integer }
              url:
                type: string
                description: |
                  URL assinada da API que faz STREAM do PDF. O visor lê o
                  arquivo por fetch e o bucket de evidências não expõe CORS —
                  buscar o S3 direto é bloqueado pelo navegador. Mesmo TTL do
                  magic link.
              download_url:
                type: string
                description: |
                  URL assinada DIRETA do objeto no storage (TTL 5 min). A
                  página de aceite NÃO a usa (o "abrir em outra aba" vai pela
                  rota da API, que dura o TTL do magic link) — fica exposta
                  para integrações que precisem do objeto direto. Curta de
                  propósito: é acesso sem passar pela aplicação.
        fields:
          type: array
          description: Campos do signatário (âncora/posição fixa).
          items: { $ref: '#/components/schemas/Field' }
        consent:
          type: object
          description: Texto e versão EXATOS do consentimento vigente.
          properties:
            text: { type: string }
            version: { type: string }
        branding: { $ref: '#/components/schemas/Branding' }
        legal_custody:
          type: string
          description: Linha de custódia — AEDOC como custodiante independente das evidências (sempre presente, inclusive white-label).
        awaiting_turn:
          type: boolean
          description: |
            Ordem sequencial: true quando o envelope está ativo mas
            ainda não é a vez deste signatário — o front mostra "aguarde sua
            vez". As `actions` CONTINUAM emitidas (a recusa é permitida de
            qualquer posição e a abertura é evidência); só o POST accept é
            barrado pelo guard de vez (422).
        otp:
          description: |
            Estado do 2º fator (gate jurídico da
            "avançada"; multi-canal). `null` para signatários sem
            método de 2º fator. Quando presente e `validated=false`, o front
            exibe a etapa de código antes de habilitar o assinar (o POST
            accept devolve 422 sem OTP validado, de toda forma — o backend é
            a regra). `channel` reflete o ÚLTIMO desafio emitido: falha de
            transporte SMS/WhatsApp com fallback honesto muda para `email`.
          oneOf:
            - type: object
              properties:
                required: { type: boolean }
                validated: { type: boolean }
                suspended:
                  type: boolean
                  description: |
                    true = o 2º fator está temporariamente indisponível
                    (canal externo fora do ar) e o signatário SÓ-WhatsApp não
                    tem outro canal — o aceite fica bloqueado até voltar
                    (nunca degrada para clique puro em silêncio).
                pending:
                  type: boolean
                  description: |
                    true = existe desafio VIVO (emitido, não consumado, não
                    expirado) — a página recarregada reabre direto o campo do
                    código em vez de exigir novo envio preso no cooldown.
                available_channels:
                  type: array
                  items: { type: string, enum: [email, sms, whatsapp] }
                  description: |
                    Canais em que ESTE signatário pode receber o código, em
                    ordem de preferência (WhatsApp → SMS → e-mail): interseção
                    dos contatos do signatário com os canais habilitados para
                    a conta. Use no radio de escolha e no `channel` do
                    otp/request.
                channel: { type: string, enum: [email, sms, whatsapp] }
                sent_to_masked: { type: [string, 'null'], description: 'Destino mascarado (ex.: j***@example.com)' }
                cooldown_seconds: { type: integer, description: Cooldown mínimo entre reenvios }
                expires_in_minutes: { type: integer, description: Validade do código }
            - { type: 'null' }
        signature:
          oneOf:
            - { $ref: '#/components/schemas/Signature' }
            - { type: 'null' }
        actions:
          description: |
            URLs ASSINADAS das ações; null quando o signatário já concluiu ou
            o envelope saiu de andamento (cancelado/expirado/encerrado).
            Presentes também em awaiting_turn (recusar/abrir permitidos;
            assinar fora da vez → 422).
          oneOf:
            - type: object
              properties:
                opened_url: { type: string }
                sign_url: { type: string }
                decline_url: { type: string }
                otp_request_url: { type: [string, 'null'], description: Presente só para signatários com método de 2º fator (otp_email/otp_sms/whatsapp) }
                otp_verify_url: { type: [string, 'null'], description: Presente só para signatários com método de 2º fator (otp_email/otp_sms/whatsapp) }
            - { type: 'null' }

    FieldInput:
      type: object
      description: |
        Posição de um campo em um arquivo+página. Coordenadas em MILÍMETROS
        absolutos da página, origem no canto superior esquerdo (x → direita,
        y → baixo) — a MESMA convenção que o renderer da finalização (1.4)
        consome ao estampar. Uma única convenção, fim a fim.
      required: [document_file_id, page, signer_id, field_type, x, y, w, h]
      properties:
        document_file_id: { type: string }
        signer_id: { type: string }
        page: { type: integer, minimum: 1 }
        field_type: { type: string, enum: [signature, initials, date, text] }
        x: { type: number, minimum: 0, description: mm a partir da borda esquerda }
        y: { type: number, minimum: 0, description: mm a partir da borda superior }
        w: { type: number, exclusiveMinimum: 0, description: largura em mm }
        h: { type: number, exclusiveMinimum: 0, description: altura em mm }
        required: { type: boolean, default: true }

    FieldUpdate:
      type: object
      description: Atualização parcial de um campo (mesma convenção do FieldInput).
      properties:
        signer_id: { type: string }
        page: { type: integer, minimum: 1 }
        field_type: { type: string, enum: [signature, initials, date, text] }
        x: { type: number, minimum: 0 }
        y: { type: number, minimum: 0 }
        w: { type: number, exclusiveMinimum: 0 }
        h: { type: number, exclusiveMinimum: 0 }
        required: { type: boolean }

    Field:
      allOf:
        - $ref: '#/components/schemas/FieldInput'
        - type: object
          properties:
            id: { type: string }

    AuditEvent:
      type: object
      description: |
        Evento append-only da trilha (hash chain). PII na trilha é só ponteiro
        (signer_id/notification_id) — nunca e-mail/CPF/telefone crus (R27).
      properties:
        id: { type: integer }
        event_type: { type: string, example: document_created }
        sequence: { type: integer }
        signer_id: { type: [string, 'null'] }
        ip_address: { type: [string, 'null'] }
        user_agent: { type: [string, 'null'] }
        geo_country: { type: [string, 'null'] }
        metadata: { type: [object, 'null'], description: Contexto do evento (ids/versões — sem PII crua) }
        event_hash: { type: string }
        previous_event_hash: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }

    DashboardMetrics:
      type: object
      description: Read model do painel — derivado por leitura pura.
      properties:
        period:
          type: object
          properties:
            from: { type: string, format: date }
            to: { type: string, format: date }
        documents:
          type: object
          description: Contadores de documentos do período (por sent_at; expirados por janela derivada).
          properties:
            sent: { type: integer, description: Enviados no período }
            signed: { type: integer, description: Enviados no período e já assinados }
            pending: { type: integer, description: Enviados no período ainda em andamento (sent/viewed/partially_signed/processing) }
            expired: { type: integer, description: Enviados no período e expirados (status ou janela vencida — regra única do deriver) }
            declined: { type: integer, description: Enviados no período e recusados }
        completion_rate:
          type: [number, 'null']
          description: signed / sent do período (null quando sent = 0).
        avg_time_to_sign_seconds:
          type: [integer, 'null']
          description: Média de completed_at - sent_at dos assinados no período (null sem amostra).
        recent_events:
          type: array
          description: Últimos eventos da trilha do tenant (leitura pura — sem PII crua).
          items:
            type: object
            properties:
              event_type: { type: string }
              document_id: { type: [string, 'null'] }
              created_at: { type: string, format: date-time }
        plan_usage:
          type: object
          description: |
            Plano e consumo do MÊS DE COMPETÊNCIA corrente (independe do
            período filtrado: é cota, não métrica).
            Consumo = documentos ENVIADOS (usage_ledger.documents_sent);
            `limit`/`remaining` null = ilimitado.
          properties:
            plan:
              type: object
              properties:
                code: { type: [string, 'null'], example: free }
                name: { type: [string, 'null'], example: Free }
            period: { type: string, format: date, description: 1º dia do mês de competência }
            limit: { type: [integer, 'null'], example: 5 }
            used: { type: integer, example: 3 }
            remaining: { type: [integer, 'null'], example: 2 }
            subscription_status:
              type: [string, 'null']
              enum: [active, trialing, past_due]
              description: |
                Estado da assinatura vigente. `past_due` com o
                limite estourado → o /send responde 402 payment_required.
            wallet:
              oneOf:
                - { $ref: '#/components/schemas/WalletSummary' }
                - { type: 'null' }
              description: null = plano sem carteira (Free/legado/Enterprise).

    PublicDocument:
      type: object
      description: |
        Projeção PÚBLICA do documento na verificação (plano §16): sem título e
        sem conteúdo (minimização); PII só mascarada (R27).
      properties:
        status: { type: string, example: signed }
        signature_level: { type: string, example: avancada }
        expired: { type: boolean, description: Expiração derivada (status expired OU expires_at vencido sem conclusão). }
        expected:
          type: object
          description: Hashes registrados na fonte única (document_hashes).
          properties:
            original_sha256: { type: [string, 'null'] }
            final_sha256: { type: [string, 'null'] }
        completed_at: { type: [string, 'null'], format: date-time }
        expires_at: { type: [string, 'null'], format: date-time }
        signers:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              email: { type: [string, 'null'], format: email, description: 'null = signatário só-WhatsApp (contato probatório = telefone mascarado)' }
              cpf_masked: { type: [string, 'null'], example: '***.456.789-**' }
              phone_masked: { type: [string, 'null'], example: '+*******4321' }
              status: { type: string, example: signed }
              signed_at: { type: [string, 'null'], format: date-time }
        auth_methods:
          type: array
          items: { type: string, example: email }

    VerificationInfo:
      type: object
      description: Payload público da página verificar/{codigo}.
      properties:
        found: { type: boolean }
        upload_max_bytes:
          type: integer
          description: Teto (bytes) do upload de conferência — o front espelha este valor.
        document:
          oneOf:
            - $ref: '#/components/schemas/PublicDocument'
            - type: 'null'
        branding: { $ref: '#/components/schemas/Branding' }
        disclaimer:
          type: string
          example: Conferência de integridade por hash SHA-256 — NÃO é validação ICP-Brasil/VALIDAR.
        legal_negative:
          type: string
          description: Linha negativa obrigatória (R1) — "NÃO é PAdES/ICP-Brasil".
        legal_custody:
          type: string
          description: Linha de custódia — AEDOC como custodiante independente das evidências (sempre presente, inclusive white-label).

    Branding:
      type: object
      description: >-
        Marca do tenant (white-label) para as superfícies públicas.
        SÓ identidade VISUAL — a moldura legal (disclaimer + "NÃO é PAdES/ICP")
        é independente e permanece SEMPRE. `logo_url` é null quando a marca é a
        AEDOC padrão (o front usa o asset local); `is_default` sinaliza esse caso.
      properties:
        name: { type: string, description: Nome exibido (razão social do tenant ou AEDOC). }
        primary_color: { type: string, description: 'Cor primária hex (ex.: #0A2A5E).' }
        accent_color: { type: string, description: Cor de acento hex. }
        logo_url:
          oneOf:
            - { type: string, format: uri }
            - type: 'null'
          description: URL temporária do logo do tenant; null na marca AEDOC padrão.
        is_default: { type: boolean, description: true quando é a marca AEDOC padrão. }

    BrandingSettings:
      type: object
      description: Marca do tenant para gestão no painel.
      properties:
        name: { type: string }
        primary_color: { type: string, example: '#0A2A5E' }
        accent_color: { type: string }
        logo_path:
          oneOf:
            - { type: string }
            - type: 'null'
        is_default_color: { type: boolean }
        logo_url:
          oneOf:
            - { type: string, format: uri }
            - type: 'null'

    VerifyResult:
      type: object
      properties:
        status:
          type: string
          description: Resultado da conferência de integridade (nunca "válido").
          enum: [integro, alterado, expirado, nao_encontrado]
        label:
          type: string
          description: Rótulo humano exato do resultado.
          enum: ['íntegro (hash confere)', 'alterado', 'expirado', 'não encontrado']
        uploaded_sha256: { type: [string, 'null'] }
        matched_purpose:
          description: Qual hash registrado bateu (quando íntegro).
          oneOf:
            - type: string
              enum: [original, final_pdf]
            - type: 'null'
        document:
          oneOf:
            - $ref: '#/components/schemas/PublicDocument'
            - type: 'null'
        disclaimer:
          type: string
          example: Conferência de integridade por hash SHA-256 — NÃO é validação ICP-Brasil/VALIDAR.
        legal_negative:
          type: string
          description: Linha negativa obrigatória (R1) — "NÃO é PAdES/ICP-Brasil".
        legal_custody:
          type: string
          description: Linha de custódia — AEDOC como custodiante independente das evidências (sempre presente, inclusive white-label).

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        active: { type: boolean }
        disabled_at: { type: [string, 'null'], format: date-time }
        disabled_reason:
          type: [string, 'null']
          description: '`manual` ou `auto: …` (endpoint falhando por dias).'
        failing_since:
          type: [string, 'null']
          format: date-time
          description: Início da sequência de falhas (zera no primeiro 2xx).
        previous_secret_expires_at:
          type: [string, 'null']
          format: date-time
          description: Fim da janela de rotação (segundo v1= no header).
        created_at: { type: string, format: date-time }

    WebhookUpdate:
      type: object
      description: Todos os campos opcionais; `active` liga/desliga o endpoint.
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WebhookEventName' }
        active: { type: boolean }

    WebhookEventName:
      type: string
      description: Catálogo assinável — imagem exata do event_map (config aedoc.webhook_events).
      enum:
        - document.created
        - document.uploaded
        - document.sent
        - document.viewed
        - signer.authenticated
        - document.partially_signed
        - document.signed
        - document.declined
        - document.expired
        - document.cancelled
        - document.failed

    WebhookInput:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WebhookEventName' }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        webhook_id: { type: string }
        event_id:
          type: string
          description: Id do evento no outbox — idempotency key estável do consumidor.
        event: { type: string }
        status: { type: string, enum: [pending, delivered, failed, exhausted] }
        attempts: { type: integer }
        response_code: { type: [integer, 'null'] }
        response_ms:
          type: [integer, 'null']
          description: Latência da última tentativa (ms).
        last_error: { type: [string, 'null'] }
        payload:
          type: object
          description: Corpo THIN enviado (ids + links — nunca PII).
        next_attempt_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookEvent:
      type: object
      description: |
        Envelope entregue ao endpoint do cliente. THIN por princípio (R28):
        ids + links para buscar o recurso pela API — NUNCA dados pessoais
        (nome/e-mail/CPF/telefone) nem conteúdo do documento.
      properties:
        event: { type: string, example: document.signed }
        event_id:
          type: string
          example: 01kwf0evt0000000000000000
          description: Idempotency key estável — deduplique por ela.
        occurred_at: { type: string, format: date-time }
        data:
          type: object
          properties:
            document_id: { type: [string, 'null'] }
            signer_id: { type: [string, 'null'] }
            links:
              type: object
              properties:
                document: { type: string, format: uri }
                audit: { type: string, format: uri }

    ApiKey:
      type: object
      description: Projeção pública da chave — nunca o segredo nem o hash (R9).
      properties:
        id: { type: string }
        name: { type: string }
        mode: { type: string, enum: [live, test] }
        token_prefix:
          type: string
          example: aedoc_live_9f2c4a…
          description: Começo não sensível da chave, para reconhecimento.
        scopes:
          type: array
          items: { type: string }
        last_used_at: { type: [string, 'null'], format: date-time }
        revoked_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    ApiKeyInput:
      type: object
      required: [name, scopes]
      properties:
        name: { type: string, maxLength: 120, example: Integração ERP }
        mode:
          type: string
          enum: [live, test]
          default: live
          description: '`test` é apenas rótulo por ora (sem sandbox dedicado).'
        scopes:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - documents:read
              - documents:write
              - documents:sign
              - signers:write
              - signers:link
              - fields:write
              - audit:read
              - templates:read
              - templates:write
              - webhooks:read
              - webhooks:write
              - billing:read

    CursorPage:
      type: object
      properties:
        data:
          type: array
          items: {}
        meta:
          type: object
          properties:
            next_cursor: { type: [string, 'null'] }
            prev_cursor: { type: [string, 'null'] }
            per_page: { type: integer }

    Error:
      type: object
      properties:
        message: { type: string }

    ValidationError:
      type: object
      properties:
        message: { type: string }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }

    BillingOverview:
      type: object
      description: |
        Plano/uso do mês (mesmo shape do plan_usage do dashboard)
        + estado de cobrança e planos contratáveis online.
      properties:
        plan:
          type: object
          properties:
            code: { type: [string, 'null'], example: professional }
            name: { type: [string, 'null'], example: Professional }
        period: { type: string, format: date }
        limit: { type: [integer, 'null'] }
        used: { type: integer }
        remaining: { type: [integer, 'null'] }
        subscription_status:
          type: [string, 'null']
          enum: [active, trialing, past_due]
        wallet:
          oneOf:
            - { $ref: '#/components/schemas/WalletSummary' }
            - { type: 'null' }
          description: null = plano sem carteira (Free/legado/Enterprise).
        billing:
          type: object
          properties:
            provider: { type: string, example: stripe }
            has_customer: { type: boolean, description: Tenant já tem customer no provedor (portal disponível) }
            upgradable_plans:
              type: array
              items:
                type: object
                properties:
                  code: { type: string }
                  name: { type: string }
                  price_cents: { type: integer }
                  currency: { type: string, example: BRL }
                  monthly_document_limit: { type: [integer, 'null'] }

    WalletSummary:
      type: object
      description: |
        Carteira pré-paga do tenant. Dinheiro em
        millicents (milésimo de centavo) no ledger; valores de exibição e
        configuração em cents. `cap_cents` = teto de ACÚMULO do crédito
        mensal (dinheiro pago via recarga não tem teto).
      properties:
        balance_millicents: { type: integer }
        balance_cents: { type: integer }
        monthly_credit_cents: { type: integer, example: 3000 }
        cap_cents: { type: [integer, 'null'], example: 9000 }
        topup_packages:
          type: array
          items: { type: integer }
          example: [3000, 6000, 15000, 30000]
        auto_recharge:
          type: object
          properties:
            enabled: { type: boolean }
            threshold_cents: { type: [integer, 'null'] }
            amount_cents: { type: [integer, 'null'] }
            monthly_cap_cents: { type: [integer, 'null'] }
            disabled_reason: { type: [string, 'null'], example: dispute }
        action_prices:
          type: array
          description: Só ações LIGADAS e com preço (espelho do que é cobrado).
          items:
            type: object
            properties:
              metric: { type: string, example: api_request }
              price_millicents: { type: integer, example: 80 }

    WalletTransaction:
      type: object
      description: |
        Lançamento do ledger append-only da carteira.
        `amount_millicents` é assinado (crédito > 0, débito < 0).
      properties:
        id: { type: string }
        type:
          type: string
          enum: [monthly_credit, topup, auto_recharge, debit, expiration, refund_reversal, adjustment]
        metric: { type: [string, 'null'], example: otp_whatsapp_sent }
        amount_millicents: { type: integer }
        balance_after_millicents: { type: integer }
        created_at: { type: [string, 'null'], format: date-time }

    AdminTenant:
      type: object
      description: Linha da lista de tenants do backoffice.
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        status: { type: string, enum: [active, suspended, cancelled] }
        created_at: { type: [string, 'null'], format: date-time }
        plan:
          type: object
          properties:
            code: { type: [string, 'null'] }
            name: { type: [string, 'null'] }
            monthly_document_limit: { type: [integer, 'null'] }
        subscription_status: { type: [string, 'null'] }
        usage:
          type: object
          properties:
            period: { type: string, format: date }
            documents_sent: { type: integer }

    AdminPlan:
      type: object
      properties:
        code: { type: string, example: pro }
        name: { type: string, example: Pro }
        price_cents: { type: integer, example: 14900 }
        currency: { type: string, example: BRL }
        monthly_document_limit: { type: [integer, 'null'], example: 150 }
        is_active: { type: boolean }

    AdminWebhookDelivery:
      type: object
      description: Entrega de webhook na visão cross-tenant do backoffice.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        webhook_id: { type: string }
        event: { type: string, example: document.signed }
        event_id: { type: string }
        status: { type: string, enum: [pending, delivered, failed, exhausted] }
        attempts: { type: integer }
        response_code: { type: [integer, 'null'] }
        last_error: { type: [string, 'null'] }
        next_attempt_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }

    # ------------------------------------------------------ Templates
    DocumentTemplate:
      type: object
      description: |
        Molde reutilizável de envelope (arquivos-base + papéis + campos). Não
        carrega PII — os dados reais dos signatários entram só na materialização.
      properties:
        id: { type: string, example: 01kwf0abcdefghijklmnopqrst }
        name: { type: string, example: Contrato de Prestação de Serviços }
        description: { type: [string, 'null'] }
        signature_level: { $ref: '#/components/schemas/SignatureLevel' }
        signing_type: { type: string, enum: [sequential, parallel] }
        default_auth_method: { $ref: '#/components/schemas/AuthMethod' }
        is_active: { type: boolean }
        created_at: { type: [string, 'null'], format: date-time }
        files:
          type: array
          description: Presente no detalhe do template.
          items: { $ref: '#/components/schemas/DocumentTemplateFile' }
        signers:
          type: array
          description: Presente no detalhe do template.
          items: { $ref: '#/components/schemas/DocumentTemplateSigner' }
        fields:
          type: array
          description: Presente no detalhe do template.
          items: { $ref: '#/components/schemas/DocumentTemplateField' }

    DocumentTemplateFile:
      type: object
      description: Arquivo-base (PDF-modelo) do template.
      properties:
        id: { type: string }
        filename: { type: string }
        byte_size: { type: integer }
        sha256: { type: string }
        page_count: { type: [integer, 'null'] }
        position: { type: integer, description: Ordem do arquivo dentro do template. }

    DocumentTemplateSigner:
      type: object
      description: |
        Signatário-modelo (papel abstrato, ex.: "Contratante"). SEM PII — os
        dados reais chegam via `assignments` na materialização.
      properties:
        id: { type: string }
        role_label: { type: string, example: Contratante }
        sign_order: { type: integer }
        auth_method: { $ref: '#/components/schemas/AuthMethod' }

    DocumentTemplateField:
      type: object
      description: |
        Campo-modelo posicionado. Coordenadas em MILÍMETROS absolutos da página,
        origem no canto superior esquerdo (x → direita, y → baixo) — a MESMA
        convenção que o renderer da finalização consome ao estampar.
      properties:
        id: { type: string }
        document_template_file_id: { type: string }
        document_template_signer_id: { type: [string, 'null'], description: Papel ao qual o campo pertence (opcional). }
        page: { type: integer, minimum: 1 }
        field_type: { type: string, enum: [signature, initials, date, text] }
        x: { type: number, minimum: 0, description: mm a partir da borda esquerda }
        y: { type: number, minimum: 0, description: mm a partir da borda superior }
        w: { type: number, exclusiveMinimum: 0, description: largura em mm }
        h: { type: number, exclusiveMinimum: 0, description: altura em mm }
        required: { type: boolean }

    CreateDocumentTemplateRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, example: Contrato de Prestação de Serviços }
        description: { type: [string, 'null'] }
        signature_level: { $ref: '#/components/schemas/SignatureLevel' }
        signing_type: { type: string, enum: [sequential, parallel], default: sequential }
        default_auth_method: { $ref: '#/components/schemas/AuthMethod' }
        is_active: { type: boolean, default: true }

    UpdateDocumentTemplateRequest:
      type: object
      description: Atualização parcial do cabeçalho do template.
      properties:
        name: { type: string }
        description: { type: [string, 'null'] }
        signature_level: { $ref: '#/components/schemas/SignatureLevel' }
        signing_type: { type: string, enum: [sequential, parallel] }
        default_auth_method: { $ref: '#/components/schemas/AuthMethod' }
        is_active: { type: boolean }

    CreateDocumentTemplateSignerRequest:
      type: object
      required: [role_label]
      properties:
        role_label: { type: string, example: Contratante }
        auth_method: { $ref: '#/components/schemas/AuthMethod' }
        sign_order:
          type: integer
          minimum: 1
          description: Omitido → próxima posição (contígua 1..N).

    CreateDocumentTemplateFieldRequest:
      type: object
      description: |
        Coordenadas em MILÍMETROS absolutos da página, origem no canto superior
        esquerdo (x → direita, y → baixo) — a convenção do renderer da
        finalização.
      required: [document_template_file_id, page, field_type, x, y, w, h]
      properties:
        document_template_file_id: { type: string }
        document_template_signer_id: { type: [string, 'null'], description: Papel ao qual o campo pertence (opcional). }
        page: { type: integer, minimum: 1 }
        field_type: { type: string, enum: [signature, initials, date, text] }
        x: { type: number, minimum: 0, description: mm a partir da borda esquerda }
        y: { type: number, minimum: 0, description: mm a partir da borda superior }
        w: { type: number, exclusiveMinimum: 0, description: largura em mm }
        h: { type: number, exclusiveMinimum: 0, description: altura em mm }
        required: { type: boolean, default: true }

    MaterializeTemplateRequest:
      type: object
      description: |
        Vincula cada papel do template (`template_signer_id`) aos dados reais do
        signatário. Todos os papéis do template precisam de um assignment (papel
        faltando → 422).
      required: [assignments]
      properties:
        title: { type: string, description: Título do documento gerado. Omitido → herda o nome do template. }
        expires_at: { type: [string, 'null'], format: date-time }
        assignments:
          type: array
          minItems: 1
          items:
            type: object
            required: [template_signer_id, name, email]
            properties:
              template_signer_id: { type: string }
              name: { type: string, example: João Silva }
              email: { type: string, format: email }
              cpf: { type: [string, 'null'], description: Opcional (minimização LGPD). }
              phone: { type: [string, 'null'], example: '+5599999999999' }
              auth_method: { $ref: '#/components/schemas/AuthMethod' }
