openapi: 3.1.0

info:
  title: Atesta — CRA Integration API
  version: 1.0.0-draft.1
  summary: Plano de control para la integración de Centrales Receptoras de Alarmas con enjambres Atesta.
  description: |
    # Atesta — API de Integración para CRA

    Plano de control (*control plane*) que permite a una Central Receptora de Alarmas
    homologada supervisar, autorizar y gobernar incidentes atendidos por un enjambre
    Atesta desplegado en una propiedad protegida.

    El plano de datos en tiempo real (telemetría, tracks, solicitudes de autorización)
    viaja por WebSocket y se define en `asyncapi.yaml`. Esta especificación cubre
    exclusivamente las operaciones síncronas y de consulta.

    ## Modelo de responsabilidad

    Atesta es **proveedor de plataforma**, no operador de seguridad privada ni
    operador de aeronaves. La responsabilidad operativa del incidente recae en la CRA
    contratante, inscrita conforme a la Ley 5/2014 de Seguridad Privada.

    En consecuencia, el contrato impone dos invariantes que **no son configurables**:

    1. **Ninguna escalada a persecución sin acuse humano.** La transición a `PURSUIT`
       exige una decisión `GRANT` emitida por un operador identificado de la CRA.
       La plataforma solicita; nunca decide.
    2. **Degradación segura por defecto.** Toda solicitud de autorización lleva un
       `timeout_s`. Agotado el plazo sin decisión, el enjambre aplica la
       `default_action` declarada en el SLA del emplazamiento (`HOLD` o `RTB`).
       El silencio nunca autoriza.

    ## Interoperabilidad con receptoras existentes

    Cada incidente y cada evento incorpora su equivalente en **SIA DC-09** y
    **Contact ID** (campo `sia`). Una CRA puede consumir Atesta a través de su software
    receptor actual y adoptar el canal enriquecido de forma incremental.

    ## Trazabilidad y cadena de custodia

    Todo evento emitido lleva `seq` monotónico por incidente y su propia firma (JWS EdDSA en producción; HMAC en el sandbox).
    Las decisiones de autorización se sellan con la identidad del operador, el instante
    y el hash del estado del enjambre en ese momento, produciendo un manifiesto de
    evidencia verificable de forma independiente (`EvidenceManifest`).

    ## Entornos

    - `sandbox` — enjambre íntegramente simulado. Sin hardware, sin espacio aéreo real.
      Es el entorno de integración y de demostración comercial.
    - `production` — requiere emplazamiento homologado y autorización operacional vigente.

  contact:
    name: Atesta — Integraciones B2B
    email: integraciones@atestadrones.com
  license:
    name: Propietaria — uso sujeto a acuerdo de integración
    identifier: LicenseRef-Atesta-Proprietary

servers:
  - url: https://sandbox.api.atestadrones.com
    description: Sandbox — enjambre simulado, datos sintéticos, sin efectos físicos.
  - url: https://api.atestadrones.com
    description: Producción — emplazamientos homologados.

security:
  - cra_oauth: []

tags:
  - name: Sites
    description: Emplazamientos protegidos y su infraestructura.
  - name: Incidents
    description: Ciclo de vida de los incidentes de seguridad.
  - name: Authorizations
    description: Decisiones humanas requeridas por el enjambre.
  - name: Commands
    description: Intervención directa del operador sobre el enjambre.
  - name: Evidence
    description: Manifiestos de evidencia y cadena de custodia.
  - name: Media
    description: Acceso a flujos de vídeo y grabaciones.
  - name: Webhooks
    description: Entrega push para CRA sin sesión WebSocket permanente.
  - name: Simulation
    description: Escenarios sintéticos. Disponible solo en sandbox.

paths:

  # ─────────────────────────────────────────────────────────────────────────
  # Sites
  # ─────────────────────────────────────────────────────────────────────────

  /v1/sites:
    get:
      tags: [Sites]
      summary: Listar emplazamientos
      description: Devuelve los emplazamientos que la CRA autenticada tiene asignados en cartera.
      operationId: listSites
      parameters:
        - $ref: '#/components/parameters/PageCursor'
        - $ref: '#/components/parameters/PageLimit'
        - name: status
          in: query
          description: Filtra por estado operativo del emplazamiento.
          schema:
            $ref: '#/components/schemas/SiteStatus'
      responses:
        '200':
          description: Página de emplazamientos.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Site'
                  page:
                    $ref: '#/components/schemas/PageInfo'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/sites/{siteId}:
    parameters:
      - $ref: '#/components/parameters/SiteId'
    get:
      tags: [Sites]
      summary: Obtener un emplazamiento
      operationId: getSite
      responses:
        '200':
          description: Emplazamiento.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Site'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/sites/{siteId}/swarm:
    parameters:
      - $ref: '#/components/parameters/SiteId'
    get:
      tags: [Sites]
      summary: Estado actual del enjambre
      description: |
        Instantánea de la flota: estado del pilar, trampillas, y los cuatro agentes
        con su rol, batería y disponibilidad. Es la vista que el operador de la CRA
        consulta antes de decidir una autorización.
      operationId: getSwarmState
      responses:
        '200':
          description: Estado del enjambre.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SwarmState'
        '404':
          $ref: '#/components/responses/NotFound'

  # ─────────────────────────────────────────────────────────────────────────
  # Incidents
  # ─────────────────────────────────────────────────────────────────────────

  /v1/incidents:
    get:
      tags: [Incidents]
      summary: Listar incidentes
      operationId: listIncidents
      parameters:
        - name: site_id
          in: query
          schema:
            type: string
            format: uuid
        - name: state
          in: query
          description: Filtra por uno o varios estados de la máquina de misión.
          schema:
            type: array
            items:
              $ref: '#/components/schemas/IncidentState'
          style: form
          explode: false
        - name: opened_after
          in: query
          schema:
            type: string
            format: date-time
        - $ref: '#/components/parameters/PageCursor'
        - $ref: '#/components/parameters/PageLimit'
      responses:
        '200':
          description: Página de incidentes.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Incident'
                  page:
                    $ref: '#/components/schemas/PageInfo'

  /v1/incidents/{incidentId}:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    get:
      tags: [Incidents]
      summary: Obtener un incidente
      operationId: getIncident
      responses:
        '200':
          description: Incidente.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Incident'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/incidents/{incidentId}/events:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    get:
      tags: [Incidents]
      summary: Reproducir el flujo de eventos de un incidente
      description: |
        Devuelve los eventos del incidente ordenados por `seq` ascendente.

        Su función principal es la **resincronización**: tras una caída de la sesión
        WebSocket, la CRA reanuda desde el último `seq` confirmado y no pierde ningún
        evento. También sirve como reproducción forense del incidente cerrado.

        El esquema de cada evento es idéntico al del canal WebSocket
        (ver `asyncapi.yaml`).
      operationId: replayIncidentEvents
      parameters:
        - name: since_seq
          in: query
          description: Devuelve únicamente eventos con `seq` estrictamente mayor.
          schema:
            type: integer
            format: int64
            minimum: 0
        - name: event_type
          in: query
          schema:
            type: array
            items:
              $ref: '#/components/schemas/EventType'
          style: form
          explode: false
        - $ref: '#/components/parameters/PageLimit'
      responses:
        '200':
          description: Eventos del incidente.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EventEnvelope'
                  page:
                    $ref: '#/components/schemas/PageInfo'

  # ─────────────────────────────────────────────────────────────────────────
  # Authorizations
  # ─────────────────────────────────────────────────────────────────────────

  /v1/incidents/{incidentId}/authorizations:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    get:
      tags: [Authorizations]
      summary: Listar solicitudes de autorización del incidente
      description: |
        Incluye las solicitudes pendientes, las resueltas y las vencidas por timeout.
      operationId: listAuthorizations
      parameters:
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/AuthorizationStatus'
      responses:
        '200':
          description: Solicitudes de autorización.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuthorizationRequest'

  /v1/incidents/{incidentId}/authorizations/{authorizationId}/decision:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
      - name: authorizationId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [Authorizations]
      summary: Resolver una solicitud de autorización
      description: |
        Registra la decisión humana que el enjambre está esperando. **Es el punto de
        control legal de todo el sistema**: sin una decisión `GRANT` aquí, el enjambre
        no escala a persecución bajo ninguna circunstancia.

        La decisión queda sellada con la identidad del operador (derivada del token),
        el instante de servidor y el hash del estado del enjambre en el momento de la
        solicitud. Este sello es la prueba de que la decisión fue humana, informada y
        trazable.

        **Idempotencia.** La cabecera `Idempotency-Key` es obligatoria. Un reintento
        con la misma clave devuelve la decisión original sin efectos secundarios.

        **Carreras contra el timeout.** Si la solicitud ya venció, la respuesta es
        `409` con `code: authorization_expired`; el enjambre ya habrá aplicado la
        `default_action`. La CRA puede entonces emitir un comando explícito por
        `/commands` si desea retomar el control.
      operationId: decideAuthorization
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthorizationDecision'
            examples:
              conceder_persecucion:
                summary: Autorizar persecución de un intruso en fuga
                value:
                  decision: GRANT
                  operator_ref: OP-4471
                  justification: Intruso verificado en vídeo, huye hacia vía pública norte.
                  constraints:
                    max_duration_s: 180
                    max_range_m: 400
                    geofence_ref: gf_lz_costa_teguise_01
              denegar:
                summary: Denegar y replegar
                value:
                  decision: DENY
                  operator_ref: OP-4471
                  justification: Falso positivo confirmado, personal de mantenimiento.
      responses:
        '200':
          description: Decisión registrada y transmitida al enjambre.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthorizationRequest'
        '409':
          description: La solicitud ya fue resuelta o venció por timeout.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ─────────────────────────────────────────────────────────────────────────
  # Commands
  # ─────────────────────────────────────────────────────────────────────────

  /v1/incidents/{incidentId}/commands:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    post:
      tags: [Commands]
      summary: Emitir un comando de intervención
      description: |
        Intervención directa del operador sobre el enjambre en curso.

        `ABORT` tiene prioridad absoluta: se propaga a los cuatro agentes y no puede
        ser rechazado por lógica de misión. Es el equivalente funcional a la parada de
        emergencia y debe estar disponible en la interfaz del operador en todo momento
        y a un solo gesto.
      operationId: issueCommand
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CommandRequest'
            examples:
              aborto:
                summary: Aborto inmediato de la misión
                value:
                  command: ABORT
                  operator_ref: OP-4471
                  reason: Solicitud del propietario, acceso autorizado no notificado.
      responses:
        '202':
          description: Comando aceptado y encolado hacia el enjambre.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommandAck'
        '409':
          description: El incidente ya está cerrado.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  # ─────────────────────────────────────────────────────────────────────────
  # Evidence
  # ─────────────────────────────────────────────────────────────────────────

  /v1/incidents/{incidentId}/evidence:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    get:
      tags: [Evidence]
      summary: Obtener el manifiesto de evidencia
      description: |
        Manifiesto sellado del incidente: hash de cada artefacto (vídeo, telemetría,
        flujo de eventos), la cadena de decisiones humanas y la firma que permite
        verificar la integridad del conjunto sin confiar en Atesta.

        Disponible una vez el incidente alcanza `INCIDENT_CLOSED`. Antes devuelve `409`.
      operationId: getEvidenceManifest
      responses:
        '200':
          description: Manifiesto de evidencia.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EvidenceManifest'
        '409':
          description: El incidente aún no está cerrado; la evidencia no está sellada.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  # ─────────────────────────────────────────────────────────────────────────
  # Media
  # ─────────────────────────────────────────────────────────────────────────

  /v1/incidents/{incidentId}/media:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    get:
      tags: [Media]
      summary: Listar flujos y grabaciones del incidente
      description: |
        Devuelve URLs firmadas y de caducidad corta. En incidentes en curso, los
        flujos WebRTC/LL-HLS son por agente; el agente en rol `PURSUER` se marca con
        `primary: true` para que el operador lo tenga en el visor principal.
      operationId: listIncidentMedia
      responses:
        '200':
          description: Flujos disponibles.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MediaStream'

  # ─────────────────────────────────────────────────────────────────────────
  # Webhooks
  # ─────────────────────────────────────────────────────────────────────────

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: Listar suscripciones webhook
      operationId: listWebhooks
      responses:
        '200':
          description: Suscripciones registradas.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscription'
    post:
      tags: [Webhooks]
      summary: Registrar una suscripción webhook
      description: |
        Alternativa al canal WebSocket para CRA cuyo software receptor no mantiene
        sesiones persistentes. Entrega *at-least-once* con firma HMAC en la cabecera
        `X-Atesta-Signature`; el receptor debe deduplicar por `event_id`.

        Los eventos `authorization.requested` se entregan también por webhook, pero
        la latencia de reintento los hace poco aptos para decisiones con `timeout_s`
        bajo. Para operación real se recomienda WebSocket, con webhook como respaldo.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookSubscriptionCreate'
      responses:
        '201':
          description: Suscripción creada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookSubscription'

  /v1/webhooks/{webhookId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [Webhooks]
      summary: Eliminar una suscripción webhook
      operationId: deleteWebhook
      responses:
        '204':
          description: Suscripción eliminada.

  # ─────────────────────────────────────────────────────────────────────────
  # Simulation
  # ─────────────────────────────────────────────────────────────────────────

  /v1/simulation/scenarios:
    get:
      tags: [Simulation]
      summary: Listar escenarios simulados disponibles
      description: |
        Catálogo de escenarios reproducibles. **Solo disponible en el servidor sandbox**;
        en producción devuelve `403`.

        Estos escenarios son el vehículo de la validación comercial: la CRA los dispara
        desde su propio SOC contra el sandbox y comprueba, con su software y su personal,
        que la integración funciona antes de firmar nada.
      operationId: listScenarios
      responses:
        '200':
          description: Escenarios disponibles.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Scenario'
        '403':
          description: Simulación no disponible en producción.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /v1/simulation/runs:
    post:
      tags: [Simulation]
      summary: Ejecutar un escenario simulado
      description: |
        Instancia un escenario contra un emplazamiento de sandbox y genera un incidente
        sintético completo, con su flujo de eventos en tiempo real por WebSocket.

        Todo lo producido lleva `simulated: true` en el envelope de evento y en el
        incidente. Esta marca es inmutable y se propaga a la evidencia: un incidente
        simulado nunca puede presentarse como real.
      operationId: startSimulationRun
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulationRunCreate'
            examples:
              fuga_con_persecucion:
                summary: Intrusión con fuga y solicitud de persecución
                value:
                  scenario_id: sc_perimeter_breach_with_flight
                  site_id: 8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f
                  speed_factor: 1.0
                  seed: 20260812
      responses:
        '201':
          description: Ejecución iniciada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulationRun'
        '403':
          description: Simulación no disponible en producción.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /v1/simulation/runs/{runId}:
    parameters:
      - $ref: '#/components/parameters/RunId'
    get:
      tags: [Simulation]
      summary: Estado de una ejecución
      operationId: getSimulationRun
      responses:
        '200':
          description: Ejecución encontrada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulationRun'
        '404':
          description: Ejecución inexistente.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
    delete:
      tags: [Simulation]
      summary: Cancelar y descartar una ejecución
      operationId: cancelSimulationRun
      responses:
        '204':
          description: Ejecución cancelada.
        '404':
          description: Ejecución inexistente.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /oauth2/token:
    post:
      tags: [Auth]
      summary: Obtener un token de acceso (OAuth 2.0 client credentials)
      description: |
        Flujo simulado. La forma del endpoint es la misma que una integracion
        real: `client_credentials` con `client_id` y `client_secret` form-encoded.
        El token devuelto es un bearer que caduca en `expires_in` segundos.
      operationId: issueToken
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:
                  type: string
                  enum: [client_credentials]
                client_id:
                  type: string
                client_secret:
                  type: string
                scope:
                  type: string
                  description: 'Scopes solicitados, separados por espacio.'
      responses:
        '200':
          description: Token emitido.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  token_type:
                    type: string
                    enum: [Bearer]
                  expires_in:
                    type: integer
                  scope:
                    type: string
        '401':
          description: Credenciales invalidas.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /v1/incidents/{incidentId}/media/{callsign}/mjpeg:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
      - $ref: '#/components/parameters/Callsign'
    get:
      tags: [Media]
      summary: Stream MJPEG de la camara de un agente
      description: |
        Stream HTTP/1.1 `multipart/x-mixed-replace` con frames JPEG continuos
        mientras el incidente esta activo. Cierra cuando el incidente pasa a
        `INCIDENT_CLOSED`.
      operationId: streamIncidentMediaMjpeg
      responses:
        '200':
          description: Stream MJPEG.
          content:
            multipart/x-mixed-replace:
              schema:
                type: string
                format: binary

  /v1/incidents/{incidentId}/media/{callsign}/snapshot:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
      - $ref: '#/components/parameters/Callsign'
    get:
      tags: [Media]
      summary: Snapshot puntual (JPEG) de la camara de un agente
      operationId: getIncidentMediaSnapshot
      responses:
        '200':
          description: Imagen JPEG.
          content:
            image/jpeg:
              schema:
                type: string
                format: binary

  /v1/incidents/{incidentId}/hold:
    parameters:
      - $ref: '#/components/parameters/IncidentId'
    post:
      tags: [Evidence]
      summary: Activar o liberar la conservación de la evidencia para acreditar hechos
      description: |
        LOPDGDD art. 22.3 y Ley 5/2014 art. 42.4. Las imágenes se suprimen en un
        máximo de un mes desde su captación, salvo que deban conservarse para
        acreditar actos contra personas, bienes o instalaciones. La CRA activa
        la conservación sin esperar a una orden judicial. Las imágenes deben
        ponerse a disposición de la autoridad competente en un máximo de 72
        horas y, si se relacionan con delitos, aportarse a las Fuerzas y Cuerpos
        de Seguridad. Mientras ``active=true`` el supresor automático respeta la
        conservación y el campo ``retention.evidence_hold`` aparece en el
        manifiesto.
      operationId: setEvidenceHold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operator_ref, reason, active]
              properties:
                operator_ref:
                  type: string
                  maxLength: 64
                reason:
                  type: string
                  minLength: 8
                  maxLength: 500
                active:
                  type: boolean
      responses:
        '200':
          description: Conservación actualizada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident_id:
                    type: string
                    format: uuid
                  evidence_hold:
                    type: boolean
                  evidence_hold_at:
                    type: string
                    format: date-time
                    nullable: true
                  evidence_hold_reason:
                    type: string
                    nullable: true
                  evidence_hold_operator:
                    type: string
                    nullable: true
                  applied_at:
                    type: string
                    format: date-time
        '404':
          description: Incidente inexistente.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: Estado del incidente o transicion del bloqueo invalida.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /v1/keys:
    get:
      tags: [Auth]
      summary: Clave publica del firmante de eventos
      description: |
        Un cliente (consola, integrador, receptor de webhook) usa
        esta clave para verificar las firmas de los eventos sin
        necesidad de poseer ningun secreto del servidor.

        - En sandbox con HMAC, ``keys`` viene vacio y la verificacion
          es simetrica con el secreto ``ATESTA_EVENT_SIGNING_KEY``.
        - En sandbox con JWS EdDSA (cuando el servidor migra a
          ``InMemoryKeyProvider`` o ``KmsKeyProvider``), ``keys[0]``
          contiene el JWK OKP/Ed25519 con el que se puede verificar
          la firma sin secreto.
      operationId: getPublicKeys
      responses:
        '200':
          description: Clave publica del firmante activo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  algorithm:
                    type: string
                    enum: [HS256, EdDSA]
                  active:
                    type: boolean
                  keys:
                    type: array
                    items:
                      type: object
                      description: "JWK (RFC 7517). Para EdDSA: OKP/Ed25519."
                  verification:
                    type: object
                    description: Guia de verificacion segun el algoritmo activo.

components:

  securitySchemes:
    cra_oauth:
      type: oauth2
      description: |
        OAuth 2.0 *client credentials*. Cada CRA recibe credenciales propias y, en
        producción, mTLS adicional. El token porta `cra_id` y el conjunto de
        `site_id` autorizados; la plataforma no confía en identificadores de
        emplazamiento enviados por el cliente sin contrastarlos con el token.

        El `operator_ref` de las decisiones identifica a la persona dentro de la CRA.
        La CRA es responsable de su unicidad y de poder resolverlo a una identidad real
        a efectos de auditoría.
      flows:
        clientCredentials:
          tokenUrl: https://auth.atestadrones.com/oauth2/token
          scopes:
            sites:read: Consulta de emplazamientos y estado del enjambre.
            incidents:read: Consulta de incidentes, eventos y media.
            incidents:authorize: Emisión de decisiones de autorización.
            incidents:command: Emisión de comandos de intervención, incluido ABORT.
            evidence:read: Descarga de manifiestos de evidencia.
            simulation:write: Ejecución de escenarios en sandbox.

  parameters:
    SiteId:
      name: siteId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    IncidentId:
      name: incidentId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    PageCursor:
      name: cursor
      in: query
      schema:
        type: string
    PageLimit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Clave única por intento lógico, generada por el cliente (UUIDv4 recomendado).
        Garantiza que un reintento de red no produzca una segunda autorización ni un
        segundo comando.
      schema:
        type: string
        maxLength: 128
    RunId:
      name: runId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Callsign:
      name: callsign
      in: path
      required: true
      schema:
        type: string
        enum: [ALPHA, BRAVO, CHARLIE, DELTA]

  responses:
    Unauthorized:
      description: Credenciales ausentes, inválidas o sin el scope requerido.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotFound:
      description: Recurso inexistente o fuera de la cartera de la CRA autenticada.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    UnprocessableEntity:
      description: Petición sintácticamente válida pero rechazada por las reglas de negocio.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

  schemas:

    # ── Emplazamiento ────────────────────────────────────────────────────

    Site:
      type: object
      required: [id, name, status, location, pillars, sla]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          examples: [Villa Corralejo Norte]
        reference:
          type: string
          description: Referencia interna del emplazamiento en los sistemas de la CRA.
          examples: [CRA-FTV-0142]
        status:
          $ref: '#/components/schemas/SiteStatus'
        location:
          $ref: '#/components/schemas/GeoPoint'
        island:
          type: string
          enum: [FUERTEVENTURA, LANZAROTE, GRAN_CANARIA, TENERIFE, LA_PALMA, LA_GOMERA, EL_HIERRO, LA_GRACIOSA]
        pillars:
          type: array
          items:
            $ref: '#/components/schemas/Pillar'
        geofences:
          type: array
          description: Perímetros operacionales autorizados para el emplazamiento.
          items:
            $ref: '#/components/schemas/Geofence'
        sla:
          $ref: '#/components/schemas/SiteSla'
        simulated:
          type: boolean
          default: false
          description: Emplazamiento sintético de sandbox. Nunca aparece en producción.

    SiteStatus:
      type: string
      enum: [ARMED, DISARMED, MAINTENANCE, OFFLINE]
      description: |
        - `ARMED`: sensores activos, enjambre disponible.
        - `DISARMED`: presencia autorizada declarada por el propietario.
        - `MAINTENANCE`: excluido del servicio, no genera incidentes.
        - `OFFLINE`: sin enlace con la plataforma. Ver `DEGRADED_SAFE`.

    SiteSla:
      type: object
      description: Parámetros contractuales que gobiernan el comportamiento automático.
      required: [dispatch_mode, authorization_timeout_s, default_action]
      properties:
        dispatch_mode:
          type: string
          enum: [AUTO_DISPATCH, OPERATOR_DISPATCH]
          description: |
            - `AUTO_DISPATCH`: el despliegue a `CONTAINMENT` no requiere acuse previo.
              La CRA es notificada y puede abortar en cualquier momento.
            - `OPERATOR_DISPATCH`: incluso el despliegue inicial exige autorización.
            La escalada a `PURSUIT` exige acuse humano en ambos modos, sin excepción.
        authorization_timeout_s:
          type: integer
          minimum: 5
          maximum: 300
          default: 30
          description: Plazo por defecto para resolver una solicitud de autorización.
        default_action:
          $ref: '#/components/schemas/DefaultAction'
        max_pursuit_duration_s:
          type: integer
          default: 180
        max_pursuit_range_m:
          type: integer
          default: 500

    DefaultAction:
      type: string
      enum: [HOLD, RTB]
      description: |
        Acción que el enjambre aplica cuando una solicitud de autorización vence sin
        decisión humana. El silencio nunca concede: `HOLD` mantiene contención,
        `RTB` repliega al pilar.

    Pillar:
      type: object
      required: [id, status, hatch_status, agents]
      properties:
        id:
          type: string
          format: uuid
        label:
          type: string
          examples: [Pilar Norte]
        location:
          $ref: '#/components/schemas/GeoPoint'
        status:
          type: string
          enum: [READY, DEPLOYING, RECOVERING, DEGRADED, OFFLINE]
        hatch_status:
          type: string
          enum: [CLOSED, OPENING, OPEN, CLOSING, JAMMED]
          description: |
            `JAMMED` es una condición de fallo crítica: impide despliegue y recuperación.
            Genera `system.fault` y bloquea la transición a `LAUNCHING`.
        power_source:
          type: string
          enum: [GRID, SOLAR, BATTERY, HYBRID]
          description: Relevante en propiedades off-grid para estimar autonomía de flota.
        power_reserve_pct:
          type: integer
          minimum: 0
          maximum: 100
        agents:
          type: array
          maxItems: 4
          items:
            $ref: '#/components/schemas/Agent'

    Agent:
      type: object
      description: Un micro-dron del enjambre.
      required: [id, callsign, role, status, battery_pct]
      properties:
        id:
          type: string
          format: uuid
        callsign:
          type: string
          enum: [ALPHA, BRAVO, CHARLIE, DELTA]
        role:
          $ref: '#/components/schemas/AgentRole'
        status:
          type: string
          enum: [DOCKED, CHARGING, AIRBORNE, RETURNING, FAULT, OFFLINE]
        battery_pct:
          type: integer
          minimum: 0
          maximum: 100
        endurance_remaining_s:
          type: integer
          description: Autonomía estimada restante en vuelo, ya descontado el margen de retorno.
        position:
          $ref: '#/components/schemas/GeoPose'
        link_quality:
          type: integer
          minimum: 0
          maximum: 100

    AgentRole:
      type: string
      enum: [IDLE, CONTAINMENT, PURSUER, COVERAGE, RELOCATOR, RTB, CHARGING]
      description: |
        Rol dentro de la doctrina del enjambre.

        - `CONTAINMENT`: posición cardinal del círculo de contención; observa y registra, sin disuasión activa.
        - `PURSUER`: agente único destacado en seguimiento del intruso en fuga.
        - `COVERAGE`: mantiene el perímetro tras el destaque del `PURSUER`.
        - `RELOCATOR`: reposiciona para cerrar el sector que deja libre el `PURSUER`.

        Solo puede existir un `PURSUER` por incidente. La plataforma rechaza cualquier
        estado de enjambre que viole esta invariante.

    Geofence:
      type: object
      required: [ref, kind, polygon]
      properties:
        ref:
          type: string
          examples: [gf_lz_costa_teguise_01]
        kind:
          type: string
          enum: [OPERATIONAL, NO_FLY, PURSUIT_LIMIT]
          description: |
            `NO_FLY` es de cumplimiento incondicional: ni una autorización de persecución
            concedida por la CRA permite atravesarlo. Refleja restricciones de espacio
            aéreo y límites de la propiedad protegida.
        polygon:
          type: array
          minItems: 4
          items:
            $ref: '#/components/schemas/GeoPoint'
        ceiling_m_agl:
          type: number

    SwarmState:
      type: object
      required: [site_id, observed_at, pillars]
      properties:
        site_id:
          type: string
          format: uuid
        observed_at:
          type: string
          format: date-time
        active_incident_id:
          type: [string, 'null']
          format: uuid
        pillars:
          type: array
          items:
            $ref: '#/components/schemas/Pillar'
        formation:
          $ref: '#/components/schemas/Formation'
        link:
          type: string
          enum: [ONLINE, DEGRADED, LOST]
          description: |
            Estado del enlace WAN entre el emplazamiento y la plataforma. En `LOST` el
            enjambre opera bajo lógica autónoma conservadora y no acepta escaladas.

    Formation:
      type: object
      description: Geometría de la formación en curso.
      properties:
        kind:
          type: string
          enum: [NONE, CONTAINMENT_RING, PURSUIT_SPLIT, RECOVERY]
        center:
          $ref: '#/components/schemas/GeoPoint'
        radius_m:
          type: number
        altitude_m_agl:
          type: number

    # ── Incidente ────────────────────────────────────────────────────────

    Incident:
      type: object
      required: [id, site_id, state, opened_at, trigger, simulated]
      properties:
        id:
          type: string
          format: uuid
        site_id:
          type: string
          format: uuid
        state:
          $ref: '#/components/schemas/IncidentState'
        severity:
          type: string
          enum: [INFO, LOW, MEDIUM, HIGH, CRITICAL]
        opened_at:
          type: string
          format: date-time
        closed_at:
          type: [string, 'null']
          format: date-time
        trigger:
          $ref: '#/components/schemas/Trigger'
        verification:
          $ref: '#/components/schemas/Verification'
        tracks:
          type: array
          description: Trayectorias de intrusos seguidas durante el incidente.
          items:
            $ref: '#/components/schemas/IntruderTrack'
        sia:
          $ref: '#/components/schemas/SiaMapping'
        last_seq:
          type: integer
          format: int64
          description: Último `seq` emitido. Punto de partida para resincronizar.
        simulated:
          type: boolean
          description: |
            Marca inmutable. Un incidente generado por `/v1/simulation/runs` la lleva a
            `true` de forma permanente y la propaga a todos sus eventos y a su evidencia.

    IncidentState:
      type: string
      enum:
        - IDLE
        - TRIGGERED
        - PENDING_DISPATCH_ACK
        - LAUNCHING
        - CONTAINMENT
        - PENDING_PURSUIT_ACK
        - PURSUIT
        - RTB
        - DOCKING
        - INCIDENT_CLOSED
        - ABORTED
        - DEGRADED_SAFE
      description: |
        Máquina de estados de misión. Transiciones válidas:

        ```
        IDLE                 -> TRIGGERED               (señal de sensor IoT)
        TRIGGERED            -> IDLE                    (descartado por filtro de falso positivo)
        TRIGGERED            -> PENDING_DISPATCH_ACK    (SLA = OPERATOR_DISPATCH)
        TRIGGERED            -> LAUNCHING               (SLA = AUTO_DISPATCH)
        PENDING_DISPATCH_ACK -> LAUNCHING               (GRANT del operador)
        PENDING_DISPATCH_ACK -> IDLE                    (DENY del operador)
        PENDING_DISPATCH_ACK -> IDLE                    (timeout, default_action)
        LAUNCHING            -> CONTAINMENT             (los 4 agentes en posición)
        LAUNCHING            -> ABORTED                 (trampilla JAMMED o fallo de flota)
        CONTAINMENT          -> PENDING_PURSUIT_ACK     (intruso abandona el perímetro)
        CONTAINMENT          -> RTB                     (incidente resuelto o batería)
        PENDING_PURSUIT_ACK  -> PURSUIT                 (GRANT del operador)
        PENDING_PURSUIT_ACK  -> CONTAINMENT             (DENY, o timeout con HOLD)
        PENDING_PURSUIT_ACK  -> RTB                     (timeout con RTB)
        PURSUIT              -> CONTAINMENT             (intruso vuelve al perímetro)
        PURSUIT              -> RTB                     (límite de tiempo/rango, o pérdida del track)
        RTB                  -> DOCKING                 (llegada al pilar)
        DOCKING              -> INCIDENT_CLOSED         (los 4 agentes asegurados)
        *                    -> ABORTED                 (comando ABORT del operador)
        *                    -> DEGRADED_SAFE           (pérdida de enlace WAN)
        DEGRADED_SAFE        -> <estado previo>         (enlace restablecido, revalidado)
        ABORTED              -> RTB                     (repliegue tras aborto)
        ```

        `INCIDENT_CLOSED` es terminal y dispara el sellado de la evidencia.

    Trigger:
      type: object
      required: [source, occurred_at]
      properties:
        source:
          type: string
          enum: [PIR_SENSOR, PERIMETER_BEAM, VIDEO_ANALYTICS, ACOUSTIC, MANUAL, FENCE_VIBRATION]
        sensor_ref:
          type: string
        occurred_at:
          type: string
          format: date-time
        zone:
          type: string
          examples: [Perímetro norte, Acceso piscina]
        confidence:
          type: number
          minimum: 0
          maximum: 1

    Verification:
      type: object
      description: |
        Resultado de la verificación del incidente. Distinguir alarma verificada de no
        verificada es determinante para la CRA: condiciona su protocolo de aviso a
        Fuerzas y Cuerpos de Seguridad.
      properties:
        status:
          type: string
          enum: [UNVERIFIED, VERIFIED_HUMAN, VERIFIED_ANIMAL, FALSE_POSITIVE]
        verified_by:
          type: string
          enum: [OPERATOR, ONBOARD_ANALYTICS]
        verified_at:
          type: string
          format: date-time
        subject_count:
          type: integer
          minimum: 0

    IntruderTrack:
      type: object
      required: [track_id, updated_at, position]
      properties:
        track_id:
          type: string
        updated_at:
          type: string
          format: date-time
        position:
          $ref: '#/components/schemas/GeoPoint'
        heading_deg:
          type: number
          minimum: 0
          maximum: 360
        speed_mps:
          type: number
        confidence:
          type: number
          minimum: 0
          maximum: 1
        lost:
          type: boolean
          default: false
          description: El track se ha perdido. Fuerza la revaluación del estado `PURSUIT`.

    SiaMapping:
      type: object
      description: |
        Equivalencia con protocolos de receptora ya desplegados en la CRA, para que
        pueda consumir Atesta con su software actual desde el primer día.
      properties:
        dc09_code:
          type: string
          description: Código SIA DC-09.
          examples: [BV]
        contact_id:
          type: string
          description: Código Contact ID (SIA-DC-05).
          examples: ['1130']
        zone:
          type: string
        account:
          type: string

    # ── Autorizaciones y comandos ────────────────────────────────────────

    AuthorizationRequest:
      type: object
      required: [id, incident_id, kind, status, requested_at, expires_at, default_action]
      properties:
        id:
          type: string
          format: uuid
        incident_id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [DISPATCH, PURSUIT, PURSUIT_EXTENSION, GEOFENCE_EXCEPTION]
        status:
          $ref: '#/components/schemas/AuthorizationStatus'
        requested_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        default_action:
          $ref: '#/components/schemas/DefaultAction'
        context:
          type: object
          description: |
            Instantánea que se presenta al operador para que decida con fundamento:
            track del intruso, estado de la flota y referencia al fotograma de
            verificación.
          properties:
            track:
              $ref: '#/components/schemas/IntruderTrack'
            swarm_snapshot_hash:
              type: string
              description: SHA-256 del estado del enjambre en el instante de la solicitud.
            key_frame_url:
              type: string
              format: uri
        decision:
          $ref: '#/components/schemas/AuthorizationDecisionRecord'

    AuthorizationStatus:
      type: string
      enum: [PENDING, GRANTED, DENIED, EXPIRED, SUPERSEDED]

    AuthorizationDecision:
      type: object
      required: [decision, operator_ref]
      properties:
        decision:
          type: string
          enum: [GRANT, DENY]
        operator_ref:
          type: string
          description: |
            Identificador del operador humano dentro de la CRA. Debe resolver a una
            persona real a efectos de auditoría; la CRA garantiza esa correspondencia.
          maxLength: 64
        justification:
          type: string
          maxLength: 500
        constraints:
          type: object
          description: Restricciones que acotan la autorización concedida.
          properties:
            max_duration_s:
              type: integer
            max_range_m:
              type: integer
            geofence_ref:
              type: string

    AuthorizationDecisionRecord:
      allOf:
        - $ref: '#/components/schemas/AuthorizationDecision'
        - type: object
          required: [decided_at, seal]
          properties:
            decided_at:
              type: string
              format: date-time
              description: Instante de servidor. El instante declarado por el cliente se ignora.
            seal:
              type: string
              description: Sello de integridad de la decisión (`sha256:<hex>` sobre su registro canónico). Vincula operador, instante y contexto. Es un hash, no una firma.

    CommandRequest:
      type: object
      required: [command, operator_ref]
      properties:
        command:
          type: string
          enum: [ABORT, RTB, HOLD, RESUME]
          description: |
            `ABORT` tiene prioridad absoluta y no puede ser rechazado por la lógica de
            misión. No hay comandos de disuasión: el enjambre solo observa y registra.
        operator_ref:
          type: string
        reason:
          type: string
          maxLength: 500
        target_agent:
          type: string
          enum: [ALPHA, BRAVO, CHARLIE, DELTA, ALL]
          default: ALL

    CommandAck:
      type: object
      required: [command_id, accepted_at, command]
      properties:
        command_id:
          type: string
          format: uuid
        command:
          type: string
        accepted_at:
          type: string
          format: date-time
        dispatched_to:
          type: array
          items:
            type: string
        note:
          type: string
          description: |
            En enlace `DEGRADED` el comando se encola y se entrega al restablecerse.
            Este campo lo advierte explícitamente al operador.

    # ── Evidencia y media ────────────────────────────────────────────────

    EvidenceManifest:
      type: object
      required: [incident_id, sealed_at, artifacts, chain_of_custody, signature]
      properties:
        incident_id:
          type: string
          format: uuid
        sealed_at:
          type: string
          format: date-time
        simulated:
          type: boolean
        artifacts:
          type: array
          items:
            type: object
            required: [kind, sha256, size_bytes]
            properties:
              kind:
                type: string
                enum: [VIDEO, TELEMETRY, EVENT_LOG, KEY_FRAME, AUDIO]
              agent_callsign:
                type: string
              sha256:
                type: string
              size_bytes:
                type: integer
                format: int64
              recorded_from:
                type: string
                format: date-time
              recorded_to:
                type: string
                format: date-time
              retrieval_url:
                type: string
                format: uri
        chain_of_custody:
          type: array
          description: Secuencia de decisiones humanas y transiciones de estado del incidente.
          items:
            type: object
            properties:
              at:
                type: string
                format: date-time
              actor:
                type: string
              action:
                type: string
              seal:
                type: string
        retention:
          type: object
          properties:
            policy:
              type: string
              examples: [LOPDGDD-1M]
            delete_after:
              type: string
              format: date-time
              description: |
                Instante de supresión automática: un mes desde el inicio de la
                captación (el ``recorded_from`` más antiguo), el plazo máximo que fija
                la LOPDGDD (art. 22.3). Excepción: conservación para acreditar hechos
                (``POST /v1/incidents/{incidentId}/hold``).
        signature:
          type: string
          description: JWS del manifiesto completo. Permite verificar integridad sin confiar en Atesta.

    MediaStream:
      type: object
      required: [agent_callsign, protocol, url, expires_at]
      properties:
        agent_callsign:
          type: string
        protocol:
          type: string
          enum: [WEBRTC, LL_HLS, MP4_RECORDING]
        url:
          type: string
          format: uri
        expires_at:
          type: string
          format: date-time
        primary:
          type: boolean
          description: Flujo recomendado para el visor principal del operador.
        latency_ms:
          type: integer

    # ── Webhooks ─────────────────────────────────────────────────────────

    WebhookSubscriptionCreate:
      type: object
      required: [url, event_types]
      properties:
        url:
          type: string
          format: uri
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        site_ids:
          type: array
          description: Restringe la suscripción. Vacío o ausente significa toda la cartera.
          items:
            type: string
            format: uuid

    WebhookSubscription:
      allOf:
        - $ref: '#/components/schemas/WebhookSubscriptionCreate'
        - type: object
          required: [id, secret_hint, created_at]
          properties:
            id:
              type: string
              format: uuid
            secret_hint:
              type: string
              description: Últimos 4 caracteres del secreto HMAC. El secreto completo solo se muestra al crearla.
            created_at:
              type: string
              format: date-time
            status:
              type: string
              enum: [ACTIVE, SUSPENDED]

    # ── Simulación ───────────────────────────────────────────────────────

    Scenario:
      type: object
      required: [id, name, description, duration_s]
      properties:
        id:
          type: string
          examples: [sc_perimeter_breach_with_flight]
        name:
          type: string
        description:
          type: string
        duration_s:
          type: integer
        exercises:
          type: array
          description: Transiciones y eventos que el escenario ejercita.
          items:
            type: string

    SimulationRunCreate:
      type: object
      required: [scenario_id, site_id]
      properties:
        scenario_id:
          type: string
        site_id:
          type: string
          format: uuid
        speed_factor:
          type: number
          minimum: 0.1
          maximum: 10
          default: 1
        seed:
          type: integer
          description: Semilla de reproducibilidad. La misma semilla produce el mismo flujo de eventos.
        auto_resolve_authorizations:
          type: string
          enum: [NONE, GRANT, DENY]
          default: NONE
          description: |
            Resuelve automáticamente las autorizaciones para demos desatendidas.
            `NONE` obliga al operador a decidir, que es el modo relevante en una
            demostración ante una CRA.

    SimulationRun:
      type: object
      required: [run_id, scenario_id, incident_id, started_at, status]
      properties:
        run_id:
          type: string
          format: uuid
        scenario_id:
          type: string
        incident_id:
          type: string
          format: uuid
          description: Incidente sintético generado. Se sigue por el canal WebSocket habitual.
        started_at:
          type: string
          format: date-time
        status:
          type: string
          enum: [RUNNING, COMPLETED, ABORTED]

    # ── Eventos (compartido con asyncapi.yaml) ───────────────────────────

    EventType:
      type: string
      enum:
        - incident.triggered
        - incident.state_changed
        - incident.verified
        - incident.closed
        - authorization.requested
        - authorization.resolved
        - authorization.expired
        - swarm.telemetry
        - swarm.formation_changed
        - agent.role_changed
        - agent.fault
        - intruder.track_updated
        - intruder.track_lost
        - media.stream_available
        - evidence.sealed
        - system.link_degraded
        - system.link_restored
        - system.fault
        - heartbeat

    EventEnvelope:
      type: object
      description: |
        Sobre común a todo evento, tanto por WebSocket como por webhook o replay REST.
        La CRA puede enrutar y deduplicar sin inspeccionar el `payload`.
      required: [event_id, event_type, occurred_at, site_id, seq, simulated, payload]
      properties:
        event_id:
          type: string
          format: uuid
          description: Único e inmutable. Clave de deduplicación en entrega at-least-once.
        event_type:
          $ref: '#/components/schemas/EventType'
        occurred_at:
          type: string
          format: date-time
        site_id:
          type: string
          format: uuid
        incident_id:
          type: [string, 'null']
          format: uuid
        seq:
          type: integer
          format: int64
          description: |
            Monotónico y sin huecos dentro de un incidente. Un salto indica pérdida:
            la CRA debe resincronizar con `GET /v1/incidents/{id}/events?since_seq=`.
        simulated:
          type: boolean
        sia:
          $ref: '#/components/schemas/SiaMapping'
        signature:
          type: string
          description: |
            Firma del evento.

            Hoy el sandbox emite por defecto ``hs256:<hex>`` (HMAC-SHA256 con
            clave compartida): suficiente para validar integridad dentro del
            entorno de integración, no para no repudio.

            **En producción** es un JWS compacto ``cabecera.carga.firma`` con
            ``alg=EdDSA`` (Ed25519) y ``kid`` y ``jwk`` en la cabecera. La carga
            es, en base64url, el material
            ``event_id|event_type|occurred_at|site_id|incident_id|seq`` en UTF-8
            (``incident_id`` vacío si no hay incidente), y la CRA lo verifica
            con la clave pública de ``GET /v1/keys`` sin poseer secreto alguno.
            Ver ``aegis_mock.events.sign_event`` y la sección de seguridad del
            dossier de integración.
        payload:
          type: object
          description: Cuerpo específico del `event_type`. Esquemas detallados en `asyncapi.yaml`.
          additionalProperties: true

    # ── Primitivas ───────────────────────────────────────────────────────

    GeoPoint:
      type: object
      required: [lat, lon]
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
          examples: [28.7412]
        lon:
          type: number
          minimum: -180
          maximum: 180
          examples: [-13.8674]
        alt_m:
          type: number
          description: Altitud sobre el elipsoide WGS-84, en metros.

    GeoPose:
      allOf:
        - $ref: '#/components/schemas/GeoPoint'
        - type: object
          properties:
            heading_deg:
              type: number
              minimum: 0
              maximum: 360
            agl_m:
              type: number
              description: Altura sobre el terreno, en metros.
            ground_speed_mps:
              type: number

    PageInfo:
      type: object
      properties:
        next_cursor:
          type: [string, 'null']
        has_more:
          type: boolean

    Problem:
      type: object
      description: Error conforme a RFC 9457 (Problem Details for HTTP APIs).
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          examples: ['https://docs.atestadrones.com/errors/authorization_expired']
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
        code:
          type: string
          examples: [authorization_expired]
        trace_id:
          type: string
