# Oposita GO — servicio API · contrato machine-readable (RNF-06).
# Fuente de verdad visible; internal/api/openapi.yaml es una copia embebida
# idéntica (verificado por test). MANTENER A MANO — sin generadores (plan §1).
openapi: 3.1.0
info:
  title: Oposita GO API
  version: 1.2.1
  description: >-
    Listados, búsqueda y detalle de oposiciones españolas para la app
    Oposita GO. JSON UTF-8, snake_case estable, nullables siempre presentes
    (CA RF-01). Plazos como "YYYY-MM-DD"; timestamps RFC3339. Errores con el
    mismo formato en todo 4xx/5xx (CA RF-05). Paginación offset page/limit,
    máximo 100 (ADR-005). El prefijo de rutas es OPOAPI_BASE_PATH (default
    "/v1"); los paths de abajo ya lo incluyen. En modo público (dev) no se
    exige API key; en producción sí (RF-13/14, ADR-003), salvo /healthz y
    este contrato. 1.2.0 es ADITIVO (RNF-06): OposicionDetail gana 20 campos
    nullables del enriquecimiento RF-18 (ADR-014/015) y /v1/system/health
    expone `enriquecimiento` por run; los ítems de listado (RF-06),
    /system/dataset y /healthz no cambian. Cliente v1.1: parsea sin cambios.
  license:
    name: MIT
    url: https://spdx.org/licenses/MIT.html
servers:
  - url: https://api.opomovil.com
    description: Producción (dominio Q8 definitivo, decisión del humano 2026-10-01)
  - url: http://localhost:8080
    description: Desarrollo local (OPOAPI_MODE=publico)
security:
  - ApiKeyHeader: []
paths:
  /healthz:
    get:
      summary: Liveness mínima pública (systemd/Caddy)
      description: Siempre sin auth, también en producción (plan §13.2).
      operationId: healthz
      security: []
      responses:
        "200":
          description: El proceso responde.
          content:
            application/json:
              schema:
                type: object
                required: [ok, version]
                properties:
                  ok:
                    type: boolean
                    examples: [true]
                  version:
                    type: string
                    examples: ["dev", "v0.1.0"]
  /v1/oposiciones:
    get:
      summary: Listado (E1) y búsqueda (E2) de oposiciones
      description: >-
        q vacío ⇒ listado filtrado (CA RF-04). Búsqueda insensible a mayúsculas
        y tolerante a acentos sobre titulo/cuerpo/organismo. limit excesivo se
        recorta a 100 (no es error); 0/negativo/no numérico ⇒ 400. Orden
        default: abiertas por plazo_fin ascendente, luego cerradas; `orden` se
        acepta e ignora en v1.
      operationId: listOposiciones
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: q
          in: query
          description: Texto libre (RF-04).
          schema: { type: string }
        - name: ambito
          in: query
          schema: { $ref: "#/components/schemas/Ambito" }
        - name: comunidad_autonoma
          in: query
          schema: { type: string }
        - name: cuerpo
          in: query
          schema: { type: string }
        - name: estado
          in: query
          schema: { $ref: "#/components/schemas/Estado" }
        - name: orden
          in: query
          description: Reservado v1 (se acepta e ignora).
          schema: { type: string }
      responses:
        "200":
          description: Página de resúmenes.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/Cache-Control" }
            Last-Modified: { $ref: "#/components/headers/Last-Modified" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListResponse" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/oposiciones/{id}:
    get:
      summary: Detalle completo (E3)
      description: id canónico (RF-10). Malformado ⇒ 400; inexistente ⇒ 404; ambos con error canónico (CA RF-05).
      operationId: getOposicion
      parameters:
        - name: id
          in: path
          required: true
          description: '"boe-<ref>" o "op-<16hex>" (domain.LooksLikeID).'
          schema: { type: string }
      responses:
        "200":
          description: Recurso completo con trazabilidad y licencia.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/Cache-Control" }
            Last-Modified: { $ref: "#/components/headers/Last-Modified" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/OposicionDetail" }
              example:
                data:
                  id: "boe-boe-a-2026-20851"
                  cuerpo: "Auxiliar Administrativo"
                  titulo: "Resolución de 5 de octubre de 2026, del Ayuntamiento de … (Comunidad de Madrid), por la que se declara aprobada la lista de admitidos y se convoca proceso selectivo"
                  ambito: "local"
                  comunidad_autonoma: "Comunidad de Madrid"
                  organismo: "Ayuntamiento (Comunidad de Madrid)"
                  plazas: null
                  plazo_inicio: "2026-10-08"
                  plazo_fin: "2026-11-05"
                  estado: "abierta"
                  boe_referencia: "BOE-A-2026-20851"
                  fuente: "boe"
                  fuente_url: "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2026-20851"
                  actualizado_en: "2026-10-08T06:00:00Z"
                  publicado_en: "2026-10-02"
                  publicado_boe: "2026-10-07"
                  plazo_calculado: true
                  fuentes:
                    - fuente: "boe"
                      fuente_url: "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2026-20851"
                      boe_referencia: "BOE-A-2026-20851"
                      raw_id: "BOE-A-2026-20851"
                      publicado_en: "2026-10-07"
                  licencia: "reutilización libre citando origen (BOE)"
                  boe_rango: "Resolución"
                  boe_rango_codigo: "R520"
                  boe_departamento: "Consejería de Hacienda y Función Pública (Comunidad de Madrid)"
                  boe_departamento_codigo: "S78"
                  boe_fecha_disposicion: "2026-10-05"
                  boe_diario_numero: 241
                  boe_seccion: "2"
                  boe_subseccion: "B"
                  boe_pagina_inicial: 93
                  boe_pagina_final: 99
                  boe_url_pdf: "https://www.boe.es/boe/dias/2026-10-07/pdfs/BOE-A-2026-20851.pdf"
                  boe_vigencia: "vigente"
                  boe_vigencia_fecha: "2026-10-07"
                  boe_materias:
                    - codigo: "141"
                      nombre: "Concursos de personal público"
                  boe_alertas:
                    - codigo: "141"
                      nombre: "Concursos de personal público"
                  boe_notas:
                    - "Bases de la convocatoria en: «Boletín Oficial de la Comunidad de Madrid» núm. 235, de 2 de octubre de 2026"
                  boe_bases_en:
                    boletin: "Boletín Oficial de la Comunidad de Madrid"
                    numero: "235"
                    fecha: "2026-10-02"
                  boe_ficha_actualizada_en: "2026-10-07T08:26:02Z"
                  plazo_procedencia: "derivado-ancla"
                  boe_plazo_texto: null
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/catalogos/{campo}:
    get:
      summary: Catálogo de valores presentes + conteo (E4, RF-02)
      operationId: getCatalogo
      parameters:
        - name: campo
          in: path
          required: true
          schema:
            type: string
            enum: [ambito, comunidad_autonoma, cuerpo, estado]
      responses:
        "200":
          description: Valores distintos del campo con su conteo (CA RF-02).
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/Cache-Control" }
            Last-Modified: { $ref: "#/components/headers/Last-Modified" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogResponse" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/system/health:
    get:
      summary: Estado rico de ingesta/dataset (E5, RNF-05)
      description: >-
        HTTP siempre 200: el estado va en `status` (degradado ⇒ campo, no
        código). Regla de alerta: dataset_age_s > 2×refresh_interval_s ⇒
        "degradado". v3 (RNF-10): además "degradado" con nota
        «enriquecimiento degradado» si la fase RF-18 cortocircuitó o sus
        anomalías activas alcanzan el umbral OPOAPI_ENRICH_FAIL_LIMIT. Cada
        last_run puede exponer `enriquecimiento` (EXCLUSIVO de este endpoint:
        /system/dataset no lo lleva). Auth exigida en producción.
      operationId: systemHealth
      responses:
        "200":
          description: Diagnóstico legible sin abrir la BD (CA RNF-05).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/system/dataset:
    get:
      summary: Antigüedad + último run por fuente (E6, aux app)
      operationId: systemDataset
      responses:
        "200":
          description: Metadatos del dataset.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/Cache-Control" }
            Last-Modified: { $ref: "#/components/headers/Last-Modified" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DatasetResponse" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/system/refresh:
    post:
      summary: Disparo manual de ingesta (Q3) — reservado, sin cablear en v1
      description: >-
        Superficie mínima para un futuro disparador HTTP. Mientras el
        adaptador ingest→api no exista, responde 501 con error canónico; el
        refresco manual soportado hoy es el subcomando `opoapi ingest-once`.
      operationId: systemRefresh
      responses:
        "200":
          description: Ciclo de refresco ejecutado.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, examples: [true] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "501":
          description: Refresco manual HTTP no cableado (ver descripción).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /v1/openapi.yaml:
    get:
      summary: Este contrato (E7 — público siempre)
      operationId: getOpenAPI
      security: []
      responses:
        "200":
          description: Documento OpenAPI 3.1 YAML.
          content:
            text/yaml:
              schema: { type: string }
components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: >-
        Clave estática de release (ADR-003; ventana de rotación con 2 claves
        simultáneas en OPOAPI_API_KEYS). No requerida en modo público (dev).
  parameters:
    Page:
      name: page
      in: query
      description: Página 1-based (ausente ⇒ 1; 0/negativo/no numérico ⇒ 400).
      schema: { type: integer, minimum: 1, default: 1 }
    Limit:
      name: limit
      in: query
      description: >-
        Tamaño de página (ausente ⇒ 20; >100 se RECORTA a 100 sin error;
        0/negativo/no numérico ⇒ 400).
      schema: { type: integer, minimum: 1, default: 20, maximum: 100 }
  headers:
    ETag:
      description: sha1(consulta + versión del dataset); cambia tras refresco que modifique (RF-12).
      schema: { type: string }
    Cache-Control:
      description: public, max-age=OPOAPI_CACHE_MAX_AGE (def 300).
      schema: { type: string }
    Last-Modified:
      description: actualizado_en global del dataset.
      schema: { type: string }
  responses:
    NotModified:
      description: Sin cambios (If-None-Match coincide) — cuerpo vacío, sin query a la BD.
    BadRequest:
      description: Parámetros inválidos (CA RF-03/RF-05).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Sin credenciales o inválidas en producción (CA RF-14). Mismo formato que los demás errores.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Recurso inexistente.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Límite superado (RNF-04); lleva Retry-After.
      headers:
        Retry-After:
          description: Segundos hasta el siguiente token.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Internal:
      description: Fallo inesperado; el detalle NUNCA se vierte al cliente (RNF-04).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Ambito:
      type: string
      enum: [estatal, autonomico, local]
    Estado:
      type: string
      enum: [abierta, cerrada, pendiente, desconocida]
    Vigencia:
      type: ["string", "null"]
      description: >-
        Estado JURÍDICO que el BOE publica sobre la disposición (RF-18),
        derivado por precedencia congelada anulada > derogada > agotada >
        vigente > desconocida ("desconocida" ⇔ ningún flag legible — no se
        inventa). ORTOGONAL a `estado` (que sigue derivado del plazo,
        ADR-011): boe_vigencia nunca lo sustituye ni lo recalcula (X4/Q13).
      enum: [vigente, derogada, anulada, agotada, desconocida, null]
    BOECodigoNombre:
      type: object
      description: "Elemento de lista del BOE (materias/alertas): código Y nombre; orden de documento XML (serialización determinista, A2)."
      required: [codigo, nombre]
      properties:
        codigo: { type: string, examples: ["141"] }
        nombre: { type: string, examples: ["Concursos de personal público"] }
    BOEBases:
      type: object
      description: Referencia al boletín autonómico con las bases (regla 4 ADR-014). `fecha` "YYYY-MM-DD" (día UTC), null si la cita no llevaba fecha legible.
      required: [boletin, numero, fecha]
      properties:
        boletin: { type: string, examples: ["Boletín Oficial de la Comunidad de Madrid"] }
        numero: { type: string, examples: ["235"] }
        fecha: { $ref: "#/components/schemas/DateString" }
    Fuente:
      type: string
      description: Opaca para el contrato — nuevos valores sin breaking change (RF-09).
      examples: [boe, jcyl, stub]
    DateString:
      type: ["string", "null"]
      description: Plazo "YYYY-MM-DD"; null si no hay dato (CA RF-01).
      examples: ["2026-11-30", null]
    DateTimeString:
      type: string
      format: date-time
      description: Timestamp RFC3339 UTC.
    Error:
      type: object
      description: Formato idéntico en todo 4xx/5xx (CA RF-05).
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [bad_request, unauthorized, not_found, rate_limited, internal]
            message:
              type: string
    OposicionSummary:
      type: object
      description: Ítem ligero de listado < 1 KB — sin boe_referencia ni fuente_url (RF-06).
      required: [id, titulo, cuerpo, ambito, plazas, plazo_fin, estado]
      properties:
        id: { type: string }
        titulo: { type: string }
        cuerpo: { type: string }
        ambito: { $ref: "#/components/schemas/Ambito" }
        plazas: { type: ["integer", "null"] }
        plazo_fin: { $ref: "#/components/schemas/DateString" }
        estado: { $ref: "#/components/schemas/Estado" }
    PageMeta:
      type: object
      required: [page, limit, total, pages, actualizado_en, q]
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        pages: { type: integer }
        actualizado_en:
          type: ["string", "null"]
          format: date-time
          description: Antigüedad global del dataset (RF-11).
        q:
          type: ["string", "null"]
          description: Eco de la búsqueda; null en listado puro.
    ListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/OposicionSummary" }
        meta: { $ref: "#/components/schemas/PageMeta" }
    SourceRef:
      type: object
      description: >-
        Trazabilidad de una fuente sobre la convocatoria (RF-10/15). v2
        (ADR-007/RF-05): cada traza expone SU fecha de publicación.
      required: [fuente, fuente_url, boe_referencia, raw_id, publicado_en]
      properties:
        fuente: { $ref: "#/components/schemas/Fuente" }
        fuente_url: { type: string }
        boe_referencia: { type: string, description: '"" si la fuente no la trae.' }
        raw_id: { type: string, description: Id externo en la fuente. }
        publicado_en:
          allOf:
            - $ref: "#/components/schemas/DateString"
          description: >-
            RF-05 v2: fecha de publicación EN ESA fuente (gaceta del BOE,
            boletín autonómico…), "YYYY-MM-DD"; null si la fuente no la trae.
            Distinta de publicado_boe de la ficha solo por autoría: esta es la
            observación de cada traza.
    OposicionDetail:
      type: object
      description: >-
        Los campos RF-01 completos + fuentes (RF-10) + licencia (RF-15) +
        fechas de publicación v2 (ADR-007: publicado_en / publicado_boe /
        plazo_calculado y fuentes[].publicado_en). 1-superset del resumen del
        listado (CA RF-05). El resumen de LISTA (OposicionSummary) NO cambia
        en v2: los campos nuevos son solo de detalle. v3 (RF-18/ADR-014 +
        ADR-015, contrato 1.2.0 ADITIVO): +20 campos nullables SIEMPRE
        presentes (18 `boe_*` + plazo_procedencia + boe_plazo_texto). Reglas:
        (1) los `boe_*` son metadatos que el BOE publica SOBRE la disposición
        — AÑADEN y CONFIRMAN, nunca sustituyen: cuerpo/titulo/organismo/
        fuente_url/plazo_*/estado conservan su verdad (fuente_url es
        byte-idéntico post-enriquecimiento, X14; los enlaces nuevos salen
        solo en boe_url_pdf/boe_notas). (2) `estado` NO se deriva de
        `boe_vigencia`: vigencia (dato jurídico del BOE) y estado (derivado
        del plazo) son ortogonales — la app decide el trato (Q13). (3) Listas:
        `null` ⇔ ficha nunca enriquecida; `[]` ⇔ enriquecida sin ítems.
        (4) `boe_enriquecido_en` (estampa local del último enriquecimiento)
        NO se expone (operativo interno). (5) `boe_plazo_texto` y
        `plazo_procedencia` documentan la matriz de tres niveles de RF-16
        v3.1: el texto poblado y el valor "prosa-boe" aparecen SOLO en fichas
        cuyo plazo corrigió RF-18b con `OPOAPI_RF18B=activo` sobre el patrón
        cerrado (R2/R3 — nunca un plazo explícito); con `off|sombra` siguen
        null/`derivado-ancla` (R1: cero escrituras). Un plazo "prosa-boe"
        conserva `plazo_calculado=true` (R5: sigue «estimado» hasta validación
        humana; la app no cambia su lógica de pintado).
      required:
        [id, cuerpo, titulo, ambito, comunidad_autonoma, organismo, plazas,
         plazo_inicio, plazo_fin, estado, boe_referencia, fuente, fuente_url,
         actualizado_en, publicado_en, publicado_boe, plazo_calculado,
         fuentes, licencia,
         boe_rango, boe_rango_codigo, boe_departamento, boe_departamento_codigo,
         boe_fecha_disposicion, boe_diario_numero, boe_seccion, boe_subseccion,
         boe_pagina_inicial, boe_pagina_final, boe_url_pdf, boe_vigencia,
         boe_vigencia_fecha, boe_materias, boe_alertas, boe_notas,
         boe_bases_en, boe_ficha_actualizada_en, plazo_procedencia,
         boe_plazo_texto]
      properties:
        id: { type: string, examples: ["boe-boe-a-2026-1234", "op-0123456789abcdef"] }
        cuerpo: { type: string }
        titulo: { type: string }
        ambito: { $ref: "#/components/schemas/Ambito" }
        comunidad_autonoma: { type: ["string", "null"] }
        organismo: { type: ["string", "null"] }
        plazas: { type: ["integer", "null"] }
        plazo_inicio: { $ref: "#/components/schemas/DateString" }
        plazo_fin: { $ref: "#/components/schemas/DateString" }
        estado: { $ref: "#/components/schemas/Estado" }
        boe_referencia: { type: ["string", "null"] }
        fuente: { $ref: "#/components/schemas/Fuente" }
        fuente_url: { type: string }
        actualizado_en: { $ref: "#/components/schemas/DateTimeString" }
        publicado_en:
          allOf:
            - $ref: "#/components/schemas/DateString"
          description: >-
            v2 ADR-007/RF-17: fecha de publicación en la fuente PRIMARIA de la
            ficha (típico: boletín autonómico), "YYYY-MM-DD". El BOE NUNCA
            escribe este campo en la ficha (su fecha viaja en publicado_boe y
            en fuentes[].publicado_en). null si no se conoce.
        publicado_boe:
          allOf:
            - $ref: "#/components/schemas/DateString"
          description: >-
            v2 ADR-007/RF-16: ANCLA LEGAL de los plazos = fecha de la gaceta
            BOE (Día 0 del cómputo). Solo la escribe una observación BOE; una
            vez fijada nunca se borra ni se pisa. Día UTC. null = aún no
            publicada/encontrada en el BOE.
        plazo_calculado:
          type: boolean
          description: >-
            v2 ADR-007/ADR-010 (D2): true ⇔ plazo_fin/plazo_inicio DERIVADOS
            desde publicado_boe (20 días hábiles estatales) — la app DEBE
            mostrar el plazo como «estimado» y distinguirlo visualmente de un
            plazo explícito de la fuente. false ⇔ plazo explícito o ausencia.
            Invariante del servidor: true ⇒ publicado_boe != null.
        fuentes:
          type: array
          items: { $ref: "#/components/schemas/SourceRef" }
        licencia:
          type: string
          description: Condiciones de uso de las fuentes activas (RF-15).
        # —— v3 (1.2.0, RF-18/ADR-014): 18 boe_* + 2 ADR-015 — lista congelada
        # del plano §5, en este orden. Nullables literales siempre presentes.
        boe_rango:
          type: ["string", "null"]
          description: Rango de la disposición según el BOE (nombre), p. ej. "Resolución". Dato del XML, nunca identidad.
          examples: ["Resolución", null]
        boe_rango_codigo:
          type: ["string", "null"]
          description: Código BOE del rango (literal fuente, va con su nombre).
          examples: ["R520", null]
        boe_departamento:
          type: ["string", "null"]
          description: Departamento/organismo emisor según el BOE (nombre).
          examples: ["Consejería de Hacienda y Función Pública (Comunidad de Madrid)", null]
        boe_departamento_codigo:
          type: ["string", "null"]
          description: Código BOE del departamento (literal fuente).
          examples: ["S78", null]
        boe_fecha_disposicion:
          allOf:
            - $ref: "#/components/schemas/DateString"
          description: Fecha de la disposición (no de la gaceta), "YYYY-MM-DD", día UTC.
        boe_diario_numero:
          type: ["integer", "null"]
          description: Número del diario oficial en que se publicó.
          examples: [241, null]
        boe_seccion:
          type: ["string", "null"]
          description: Sección de la gaceta, LITERAL fuente (p. ej. "2" = II Autoridades y Personal).
          examples: ["2", null]
        boe_subseccion:
          type: ["string", "null"]
          description: Subsección de la gaceta, literal fuente (p. ej. "B").
          examples: ["B", null]
        boe_pagina_inicial:
          type: ["integer", "null"]
          description: Página inicial en la gaceta.
        boe_pagina_final:
          type: ["integer", "null"]
          description: Página final en la gaceta.
        boe_url_pdf:
          type: ["string", "null"]
          description: >-
            Enlace PDF canónico que publica el propio BOE, guardado TAL CUAL
            llega. Un 404 puede ser transitorio (propagación de gaceta recién
            publicada, X13): la enlazabilidad NUNCA es condición de datos. Los
            enlaces nuevos salen SOLO aquí y en boe_notas — fuente_url no se
            toca (X14).
          examples: ["https://www.boe.es/boe/dias/2026-10-07/pdfs/BOE-A-2026-20851.pdf", null]
        boe_vigencia:
          $ref: "#/components/schemas/Vigencia"
        boe_vigencia_fecha:
          allOf:
            - $ref: "#/components/schemas/DateString"
          description: Fecha asociada a la vigencia (anulación/derogación/vigencia), "YYYY-MM-DD"; null si el BOE no la publica.
        boe_materias:
          type: ["array", "null"]
          description: Materias BOE (código Y nombre), en orden de documento XML (determinista). `null` ⇔ ficha nunca enriquecida; `[]` ⇔ enriquecida sin materias.
          items: { $ref: "#/components/schemas/BOECodigoNombre" }
        boe_alertas:
          type: ["array", "null"]
          description: Alertas BOE (código Y nombre). Misma semántica null/[] que boe_materias.
          items: { $ref: "#/components/schemas/BOECodigoNombre" }
        boe_notas:
          type: ["array", "null"]
          description: Notas LITERALES de la ficha (trazabilidad — el texto se conserva como llega). Misma semántica null/[] que boe_materias.
          items: { type: string }
        boe_bases_en:
          description: >-
            Referencia autonómica citada en una nota («Bases de la convocatoria
            en: …»), extraída con patrón CERRADO (regla 4 ADR-014): solo puebla
            huecos — si publicado_en ya tiene fecha de una fuente autonómica,
            esa manda y aquí sale igualmente el metadato. null si la ficha no
            trae esa nota o no casa el patrón.
          oneOf:
            - $ref: "#/components/schemas/BOEBases"
            - type: "null"
        boe_ficha_actualizada_en:
          type: ["string", "null"]
          format: date-time
          description: >-
            Timestamp RFC3339 UTC de la última actualización de la ficha POR
            EL BOE (atributo documento/@fecha_actualizacion). NO es la fecha
            de nuestro enriquecimiento (esa estampa local no se expone).
          examples: ["2026-10-07T08:26:02Z", null]
        plazo_procedencia:
          type: ["string", "null"]
          description: >-
            ADR-015/ADR-016 (1.2.1, vocabulario RF-16 v3.1): procedencia del
            plazo según la matriz de TRES NIVELES, por precedencia — null ⇔
            plazo EXPLÍCITO de dataset o ausencia (nunca pisado por derivación
            ni por prosa) > "prosa-boe" (plazo corregido desde la prosa del
            extracto BOE por RF-18b: alcanzable con OPOAPI_RF18B=activo y
            patrón cerrado R2, sustituye únicamente a un "derivado-ancla" R3) >
            "derivado-ancla" (fórmula RF-16 sobre publicado_boe). Metadato de
            auditoría: la app pinta «estimado» leyendo plazo_calculado, igual
            en los tres niveles (R5: un "prosa-boe" conserva
            plazo_calculado=true; jamás false desde la rama prosa).
          enum: ["derivado-ancla", "prosa-boe", null]
        boe_plazo_texto:
          type: ["string", "null"]
          description: >-
            RF-18b (ADR-016, 1.2.1): frase LITERAL de plazo del extracto BOE,
            tal como se persistió. POBLABLE: sale con valor exactamente en las
            fichas cuyo plazo corrigió la rama prosa (plazo_procedencia =
            "prosa-boe", solo con OPOAPI_RF18B=activo y patrón cerrado R2);
            null en el resto — con off|sombra el texto nunca se persiste (R1)
            y el ciclo idempotente que confirma el derivado tampoco lo
            escribe (R6). La frase casa el patrón canónico, que no admite
            nombres de personas por construcción (RNF-04).
          examples: [null]
    CatalogEntry:
      type: object
      required: [valor, count]
      properties:
        valor: { type: string }
        count: { type: integer }
    CatalogResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/CatalogEntry" }
        meta:
          type: object
          required: [campo, actualizado_en]
          properties:
            campo: { type: string }
            actualizado_en: { type: ["string", "null"], format: date-time }
    RunRecord:
      type: object
      description: >-
        Ejecución de ingesta por fuente (CA RF-09). E6 es byte-estable (v3):
        las estadísticas de la fase RF-18 del run NO salen aquí — se exponen
        SOLO en /v1/system/health (schema Enriquecimiento).
      required: [fuente, started_at, finished_at, status, error, created, updated, unchanged]
      properties:
        fuente: { $ref: "#/components/schemas/Fuente" }
        started_at: { $ref: "#/components/schemas/DateTimeString" }
        finished_at: { type: ["string", "null"], format: date-time }
        status: { type: string, enum: [ok, error] }
        error: { type: string, description: '"" si status=ok.' }
        created: { type: integer }
        updated: { type: integer }
        unchanged: { type: integer }
    Enriquecimiento:
      type: object
      description: >-
        Estadísticas de la fase de enriquecimiento RF-18 de un run (RNF-10,
        ADR-014 §7) — SOLO en E5. `presupuestados/solicitados` = tope del
        ciclo vs peticiones hechas (consumo del presupuesto saliente);
        `pendientes` = cola derivada no servida (backlog visible, A9);
        `cortocircuito=true` ⇒ la fase se abortó por ≥ OPOAPI_ENRICH_FAIL_LIMIT
        fallos de transporte consecutivos y /health pasa "degradado" con nota
        «enriquecimiento degradado» (igual si las anomalías activas alcanzan
        ese umbral). `anomalias_por_kind` ordenado por clave (determinista).
        `rf18b_sombra` = casos registrados en modo sombra (informativo; cero
        escrituras, ADR-015). `nota` p. ej. "apagado" cuando la fase no corrió
        (kill-switch). La fase puede registrarse en el run de la fuente `boe`
        o en una fila sintética de fuente "enriquecimiento". 1.2.1 (ADR-016
        §4, A7): +6 contadores OPCIONALES `rf18b_*` de telemetría RF-18b —
        identidad por ciclo verificable (AC-5): rf18b_analizadas ==
        rf18b_casadas + rf18b_no_casadas; claves ausentes ⇔ run escrito
        antes de v3.1 (filas antiguas hidratan los seis a 0/null — lectura
        tolerante, cero migración).
      required: [presupuestados, solicitados, enriquecidas, sin_cambio, anomalias,
                 anomalias_por_kind, pendientes, cortocircuito, rf18b_sombra, nota]
      properties:
        presupuestados: { type: integer, examples: [60] }
        solicitados: { type: integer, examples: [17] }
        enriquecidas: { type: integer, examples: [12] }
        sin_cambio: { type: integer, examples: [3] }
        anomalias: { type: integer, examples: [2] }
        anomalias_por_kind:
          type: object
          description: "Conteo por tipo de anomalía (claves del vocabulario ADR-014: ref-invalida, ref-discrepante, ancla-discrepante, xml-invalido, error-desconocido)."
          additionalProperties: { type: integer }
          examples: [{ref-desconocida: 1, xml-invalido: 1}]
        pendientes: { type: integer, examples: [43] }
        cortocircuito: { type: boolean }
        rf18b_sombra: { type: integer }
        nota: { type: string, description: '"" si nada que añadir; "apagado" ⇔ fase deshabilitada (kill-switch OPOAPI_ENRICH=0).' }
        # —— v3.1 (1.2.1, ADR-016 §4 / A7): telemetría RF-18b. 6 props
        # OPCIONALES (fuera del `required` de arriba — filas antiguas sin
        # estas claves hidratan a 0/null; serialización nueva las emite al
        # FINAL, en este orden de declaración, tras "nota").
        rf18b_analizadas:
          type: integer
          description: >-
            RF-18b R7: fichas a las que se llamó al parser (puerta R3
            superada; las «solicitadas» de R7). Con off: 0 — el parser ni se
            invierte (AC-5, cero coste).
          examples: [24]
        rf18b_casadas:
          type: integer
          description: >-
            Frases que casaron el patrón CERRADO completo (R2), en sombra Y
            activo. Identidad por ciclo: rf18b_analizadas == rf18b_casadas +
            rf18b_no_casadas.
          examples: [20]
        rf18b_no_casadas:
          type: integer
          description: >-
            Fichas analizadas cuyo texto no casó el patrón — cada una
            registrada con su motivo (histograma abajo; línea «rf18b-no-casado»
            en journal, tope 500/ciclo). Obligatorio en AMBOS modos
            (resolución QA12B-S1).
          examples: [4]
        rf18b_no_casados_por_motivo:
          type: ["object", "null"]
          description: >-
            Histograma de no-casados por motivo, ordenado por clave
            (determinista). Claves = enum CERRADO de R7 (≤5):
            sin-mencion-de-plazo, numeral-fuera-de-tabla, unidad-no-listada,
            anclaje-no-listado, patron-ambiguo. null ⇔ ninguno (muestra
            idéntica).
          additionalProperties: { type: integer }
          examples: [{sin-mencion-de-plazo: 1, anclaje-no-listado: 1, unidad-no-listada: 2}]
        rf18b_muestra_no_casados:
          type: ["array", "null"]
          description: >-
            Muestra de ids de fichas no-casadas: ≤50, orden de consumo
            (determinista). Con corpus ≤50 no-casadas el listado es completo
            en BD (AC-5); con más, el íntegro vive en journal y aquí la
            muestra. nil ⇔ ninguno ⇒ null.
          items: { type: string }
          examples: [["boe-boe-a-2026-21001"], null]
        rf18b_escritas:
          type: integer
          description: >-
            Escrituras de plazo R4 del ciclo (fichas corregidas): >0 SOLO con
            OPOAPI_RF18B=activo (R9 — registro del 1.er ciclo); con sombra u
            off: 0 (R1, contrato de cero escrituras). rf18b_sombra NO se
            mueve en activo (es el contador de la fase sombra).
          examples: [22]
    HealthResponse:
      type: object
      description: >-
        Estado diagnóstico (RNF-05). HTTP 200 incluso en degradado/roto. v3
        (RNF-10): degradado TAMBIÉN si la fase RF-18 cortocircuitó o las
        anomalías activas alcanzan OPOAPI_ENRICH_FAIL_LIMIT — siempre con la
        nota «enriquecimiento degradado». Cada run puede exponer
        `enriquecimiento` (solo aquí; E6 no lo lleva). La fase puede aparecer
        como fuente adicional "enriquecimiento" (fila sintética) o colgada del
        run de "boe".
      required: [status, version, time, refresh_interval_s, dataset_age_s, dataset_updated, sources, notes]
      properties:
        status: { type: string, enum: [ok, degradado, roto] }
        version: { type: string }
        time: { $ref: "#/components/schemas/DateTimeString" }
        refresh_interval_s: { type: ["number", "null"] }
        dataset_age_s: { type: ["number", "null"] }
        dataset_updated: { type: ["string", "null"], format: date-time }
        sources:
          type: array
          items:
            type: object
            required: [fuente, last_run, status, error]
            properties:
              fuente: { $ref: "#/components/schemas/Fuente" }
              last_run:
                type: ["object", "null"]
                description: >-
                  Último run de la fuente (sin campo fuente, que ya está arriba);
                  null si nunca ejecutó (status="sin_runs").
                required: [started_at, finished_at, status, error, created, updated, unchanged]
                properties:
                  started_at: { $ref: "#/components/schemas/DateTimeString" }
                  finished_at: { type: ["string", "null"], format: date-time }
                  status: { type: string, enum: [ok, error] }
                  error: { type: string }
                  created: { type: integer }
                  updated: { type: integer }
                  unchanged: { type: integer }
                  enriquecimiento:
                    oneOf:
                      - $ref: "#/components/schemas/Enriquecimiento"
                      - type: "null"
                    description: >-
                      Fase RF-18 de este run (v3, aditivo); null ⇔ la fase no
                      corrió en el run. Exposición EXCLUSIVA de E5.
              status: { type: string, enum: [ok, error, sin_runs] }
              error: { type: string }
        notes:
          type: array
          items: { type: string }
    DatasetResponse:
      type: object
      required: [actualizado_en, sources]
      properties:
        actualizado_en: { type: ["string", "null"], format: date-time }
        sources:
          type: array
          description: Último run por fuente.
          items: { $ref: "#/components/schemas/RunRecord" }
