openapi: 3.1.0
info:
  title: Panadata API v5 (agent-native)
  version: "5.0.0-alpha"
  description: >
    Aditiva sobre la v4 congelada. Las entidades Y los activos se direccionan por **ids
    públicos opacos tipados** (Layer 1, issue #3) — el id lleva la jurisdicción, así que
    las rutas por id no reciben `country`. v5 reutiliza el catálogo de productos y la
    facturación por créditos de v4; toda ruta facturable soporta `?dry_run=1` para
    previsualizar el costo. Panamá está en vivo; colombia/ecuador devuelven 400.

servers:
  - url: https://api.panadata.net

security:
  - bearerKey: []

components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "Authorization: Bearer pk_..."
  schemas:
    PublicId:
      type: string
      description: >
        Id opaco tipado: `{jur}_{type}_{pk}`. jur ∈ pa|co|ec. type ∈ own (entidad),
        finca|ph|edif (bienes raíces), nave|bm|marca|imp|exp (activos), entrada|elemento|relemento
        (expedientes registrales; elemento = de un owner, relemento = de una finca/PH),
        mig|wp|visaaut|imped (procesos migratorios — detalle bajo DAT-LABOR), gov (entidad de
        gobierno — institución pública de Panamá, catálogo con identidad propia).
        Registro de tipos append-only; el decode es estricto (id inválido → 400).
      pattern: '^(pa|co|ec)_(own|finca|ph|edif|nave|bm|marca|imp|exp|entrada|elemento|relemento|mig|wp|visaaut|imped|gov)_[0-9]+$'
      examples: ["pa_own_42", "pa_finca_153", "pa_entrada_88", "pa_gov_7"]
    GovEntitySummary:
      type: object
      properties:
        id: { $ref: '#/components/schemas/PublicId' }
        nombre: { type: string }
        tipo: { $ref: '#/components/schemas/GovEntityTipo' }
        provincia: { type: string, nullable: true, description: "1..13 (primer segmento del RUC) o null (nacional)" }
        match: { type: string, enum: [alias_exacto, nombre_norm, auto, ambiguo, similar] }
        score: { type: number, description: "1.0 para alias_exacto|nombre_norm|ambiguo (el texto ES un nombre conocido); similitud pg_trgm (0.5..1) para similar|auto" }
        alias_usado: { type: string, description: "alias normalizado que produjo un hit `similar`" }
    GovEntityTipo:
      type: string
      enum: [localidad, junta_comunal, educacion, autoridad_autonomo, municipio, organismo_internacional,
             judicial_electoral, cuerpo_diplomatico, gobierno_central, banca_estatal, seguridad,
             concejo_municipal, notaria, salud, no_gobierno]
    GovEntityDetail:
      type: object
      properties:
        id: { $ref: '#/components/schemas/PublicId' }
        country: { type: string }
        nombre_canonico: { type: string }
        tipo: { $ref: '#/components/schemas/GovEntityTipo' }
        provincia: { type: string, nullable: true }
        fuente: { type: string, enum: [legacy, dgi_n30, manual] }
        parent: { type: object, nullable: true, properties: { id: { $ref: '#/components/schemas/PublicId' }, nombre: { type: string } } }
        rucs:
          type: array
          description: "TODOS los RUC de la institución (la AMP tiene dos). Canónico en mayúsculas, sin DV."
          items: { type: object, properties: { ruc: { type: string }, tipo_ruc: { type: string, enum: [nt, regular] }, vigente: { type: boolean } } }
        aliases:
          type: array
          description: "Todas las grafías conocidas con su procedencia; los alias `descartado` se excluyen."
          items: { type: object, properties: { alias: { type: string }, fuente: { type: string }, confianza: { type: string, enum: [exacto, auto, manual] } } }
        updated_at: { type: string, nullable: true }
        redirected_from: { $ref: '#/components/schemas/PublicId', description: "presente cuando el id pedido fue fusionado en esta entidad" }
    GovEntityResolution:
      type: object
      properties:
        input: { type: string }
        entity_id: { $ref: '#/components/schemas/PublicId', nullable: true }
        nombre_canonico: { type: string, nullable: true }
        match: { type: string, enum: [alias_exacto, nombre_norm, auto, ambiguo, candidato, sin_match, vacio, descartado] }
        score: { type: number, nullable: true, description: "1.0 para alias_exacto|nombre_norm|ambiguo (la entrada ES un nombre conocido; en ambiguo la identidad no es única, ver alternativas); para auto|candidato|sin_match la similitud del mejor alias (null cuando ninguno ≥ 0.5); null para vacio|descartado. Cada entrada de alternativas lleva su propio score" }
        alternativas: { type: array, items: { $ref: '#/components/schemas/GovEntitySummary' }, description: "primero los homónimos (match ambiguo, score 1.0), después las entidades similares con su propio score; cada entidad a lo sumo una vez" }
    ResolveCandidate:
      type: object
      properties:
        gid: { $ref: '#/components/schemas/PublicId' }
        country: { type: string }
        confidence: { type: number, description: "0..1" }
        match: { type: string, enum: [high, medium, low] }
        matched_on:
          type: array
          items: { type: string, description: "name | ruc | cedula" }
        summary: { type: object }
    DryRun:
      type: object
      properties:
        dry_run: { type: boolean }
        would_charge: { type: string }
        credits: { type: number }
        product_codes: { type: array, items: { type: string } }
  parameters:
    PathId: { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
    PageLimit: { name: limit, in: query, schema: { type: integer }, description: "tamaño de página (sub-recursos ≤200; search ≤25)" }
    PageOffset: { name: offset, in: query, schema: { type: integer }, description: "offset de página (search: tope duro 200)" }
    DryRunQ: { name: dry_run, in: query, schema: { type: boolean } }

paths:
  /v5/entities:
    get:
      summary: Buscar entidades (nombre/RUC); resúmenes rankeados con ids tipados
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: ruc, in: query, schema: { type: string } }
        - { name: country, in: query, schema: { type: string, default: panama } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 50 } }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses: { "200": { description: resultados } }
  /v5/resolve:
    post:
      summary: "#4 — resolver nombre/ruc/cédula → gids candidatos rankeados (probabilístico)"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                ruc: { type: string }
                cedula: { type: string }
                country: { type: string }
      responses:
        "200":
          description: candidatos rankeados (+ grupos cross-jurisdicción vacíos hasta pdlayer#796)
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
                  groups: { type: array, items: { type: object } }
  /v5/entities/{id}:
    get:
      summary: Detalle de entidad (own) o de un activo, por id tipado
      description: >
        Los ids de activo (pa_finca_*, pa_ph_*) devuelven el mismo ítem inmobiliario
        enriquecido que entrega el endpoint de portafolio (más la geometría ANATI
        completa): precio_por_m2 con precedencia propio→edificio, precio_por_m2_origen,
        y un valor_estimado calculado con esa misma tasa efectiva — ver
        /v5/entities/{id}/real-estate para la semántica completa de los campos.
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: include, in: query, schema: { type: string }, description: "códigos de producto separados por coma (solo own)" }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses:
        "200": { description: entidad o activo }
        "400": { description: id inválido }
        "404": { description: no encontrado }
  # POST /v5/entities/{id}/update — re-scrape de un ACTIVO (finca / PH / edificio), epic #14.
  # Encola una fila `automatic` en jobs-api y se auto-completa por el pipeline normal
  # (scrape → pdf → OCR → dataland); los datos refrescados se leen después por las rutas
  # v5 cobradas. Cobra solo la tarifa base; tope anti-abuso de re-scrapes por key (429).
  # Los OWNERS (pa_own_*) se actualizan por POST /v4/{jur}/entidades/{pk}/update.
  /v5/entities/{id}/update:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    post:
      summary: "Dispara un re-scrape asíncrono de un ACTIVO (finca / PH / edificio) — solo tarifa base"
      description: >
        finca / PH = esa propiedad (eje ficha). edificio (pa_edif_*) = vuelve a traer todas
        las unidades del edificio vía una búsqueda de listado (eje nombre). Los owners NO se
        aceptan aquí (400 → usar POST /v4/{jurisdiction}/entidades/{pk}/update). Sujeto a la
        cuota de re-scrapes por key (429). Sin body.
      responses:
        "202": { description: "{enqueued: true, asset_id, identifier, update_request_id, lane: automatic, note}" }
        "400": { description: "id no panameño, id de owner, o un tipo de activo distinto de finca/ph/edif" }
        "404": { description: "activo no encontrado (sin identificador de scrape)" }
        "429": { description: "cuota de re-scrapes agotada para esta key" }
        "502": { description: "{error: scrape enqueue failed, detail}" }
  /v5/entities/batch:
    post:
      summary: Resolver hasta 100 ids tipados en una llamada
      description: >
        Cada id se resuelve exactamente igual que GET /v5/entities/{id} — los ids de
        activo (pa_finca_*, pa_ph_*) llevan la semántica del ítem inmobiliario
        enriquecido (precedencia propio→edificio de precio_por_m2, precio_por_m2_origen,
        valor_estimado consistente) documentada en /v5/entities/{id}/real-estate.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, items: { $ref: '#/components/schemas/PublicId' }, maxItems: 100 }
      responses: { "200": { description: "registros por id con flag found; un id que falla degrada a {found:false, error} y nunca tumba el resto del batch" } }
  /v5/entities/{id}/real-estate:
    get:
      summary: Portafolio inmobiliario — precio_por_m2 + promedio ponderado por superficie
      description: >
        precio_por_m2 tiene precedencia propio→edificio: gana la tasa propia de la unidad
        derivada de ventas (average_ph_price); el promedio del edificio solo llena vacíos.
        Cada ítem lleva precio_por_m2_origen ("propio" | "edificio" | null) y
        valor_estimado se calcula siempre con la MISMA tasa efectiva. Cada ítem lleva
        además valor_estimado_confianza (entero 1-100): confianza en evidencia de mercado
        del estimado, calculada con los contadores de ventas por año del edificio sobre
        ventanas de año CALENDARIO (volumen 10a + recencia 3a; una venta propia reciente
        le pone piso de 70). Es null siempre que valor_estimado sea null (y mientras los
        contadores de un edificio no estén backfilled). Ojo: este campo es 1-100 por
        diseño, a diferencia de la confidence 0..1 de resolve. NOTA la asimetría
        detalle/búsqueda: el filtro `price_m2` de la búsqueda inmobiliaria solo matchea
        la tasa PROPIA de la unidad en el índice — las unidades cuyo detalle muestra una
        tasa heredada NO matchean ese filtro.
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: include_historical, in: query, schema: { type: boolean }, description: "devuelve también tenencias desinvertidas (activo=false), etiquetadas" }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses: { "200": { description: "{entity, summary, items[]}" } }
  /v5/entities/{id}/dockets:
    get:
      summary: Listar expedientes registrales (entradas + elementos)
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: kind, in: query, schema: { type: string, enum: [all, entrada, elemento], default: all } }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses: { "200": { description: "{entity, summary, dockets[]}" } }
  /v5/entities/{id}/dockets/{docket_id}:
    get:
      summary: Un expediente + texto OCR (docket_id codifica entrada vs elemento)
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: docket_id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses: { "200": { description: expediente con ocr_text } }
  # Sub-recursos de datos por owner (epic #14). Paginados (?limit ≤200 / ?offset),
  # las formas de facets/registros varían; cada uno factura el código de producto anotado + base; todos honran ?dry_run.
  /v5/entities/{id}/importaciones:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Importaciones aduaneras — DAT-TRADE", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/exportaciones:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Exportaciones aduaneras — DAT-TRADE", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/trade-profile:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get:
      summary: "Resumen aduanero derivado + flag de actividad inconsistente + panadata_score — DAT-TRADE"
      description: "`import_tariff_activity_similarity` / `export_tariff_activity_similarity` (`null` cuando la entidad no tiene registros aduaneros de ese lado, no tiene aviso de operación vigente, o ninguna de sus partidas está en el catálogo de embeddings) = {sum, len, total, value_share_85, total_normalized, intermediary, revision, computed_at, <año>: {sum, len, total, value_share_85, total_normalized}}. `len` = embarques puntuados. `total` = media ponderada por embarque del coseno máximo (multilingual-e5) entre la descripción oficial de cada partida y las actividades declaradas de la entidad; su piso para texto no relacionado es ~0,83, así que NO es comparable entre entidades y NO es la base de la etiqueta. `value_share_85` = proporción (0-1) de embarques cuya partida casa con una actividad declarada a coseno mayor que 0,85: la señal real de coherencia partida↔actividad. `total_normalized` en baja | media | alta se deriva de `value_share_85` con cortes fijos (baja < 0,25 ≤ media < 0,60 ≤ alta), nunca de `total`. `intermediary` = true cuando al menos el 50 % de las actividades ISIC declaradas son mayoreo, menudeo, transporte, logística o correo (divisiones 46, 47, 49-53): esas entidades mueven de todo por naturaleza, así que una proporción baja es lo esperable ahí, no una anomalía. `revision` identifica el algoritmo y la escala (2 = e5 por código + etiqueta por share; un objeto SIN `revision` lo calculó el algoritmo por registro ya retirado, en otra escala): solo comparar objetos que traen la misma revisión. `computed_at` = ISO-8601 UTC. Por año, `total_normalized` y `value_share_85` son `null` cuando el `len` de ese año es 0 (sin embarques)."
      responses: { "200": { description: perfil comercial } }
  /v5/entities/{id}/licitaciones:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Licitaciones públicas — DAT-PROC", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/contraloria:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Contratos de Contraloría — DAT-CGR", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/avisos:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Avisos de operación — DAT-BIZ", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/marcas:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Marcas — DAT-IP", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/gacetas:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Gaceta Oficial (pdf_url + ocr_text) — DAT-DOC", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/noticias:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Medios adversos (pdf_url + ocr_text) — DAT-NEWS", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/css-morosos:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Morosidad CSS (sensible) — DAT-RISK", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/legal:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Expedientes judiciales (sensible) — DAT-RISK", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/screening:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "AML: coincidencias en sanciones locales + internacionales + PEP + riesgo reputacional pre-computado — DAT-SCREEN", description: "Agrega `risk` = {kinds, notas}. `risk.kinds.reputacional` es el score reputacional pre-computado (un kind nuevo entra bajo `kinds` sin romper el contrato); `risk.notas` viaja SIEMPRE (objeto vacío en el camino feliz) con `{motivo}` por kind (código legible por máquina; no viaja detalle en texto libre), motivo en sin_score | sin_cobertura | sin_breakdown | drift_version | screening_fallido | error (drift_version = las versiones de REGLAS/PESOS de esta imagen difieren de aquellas con las que se computó el score persistido, así que no se recomputa nada; error cubre además que el modo fresco falle por su cuenta, en cuyo caso `components` / `score_recomputado` / `score_sin_screening` igual viajan). Campos cuando están presentes: `score` (1-100), `computed_at`, `rules_version`, `pesos_version`, `cobertura`, `senales[]`, `sin_evaluar[]`, `components[]` ({clave, puntos, estado, explicacion, cita, procedencia?}), `score_recomputado`, `score_sin_screening`, `score_fresco`, `delta`, `cobertura_fresca`, `components_frescos`, `screening_fresco_at`. En el nivel raíz (fuera de `risk`, así que está ahí incluso cuando la entidad no tiene score): `sanctions_international_status` = ok | fallido — el estado de la pasada en vivo contra sanctions.io de ESTA llamada. `fallido` significa que la pasada no pudo correr, así que un `sanctions_international` vacío NO dice NADA sobre la entidad; hoy la mayoría de las entidades no tiene score, y la nota `sin_score` taparía la falla del proveedor. Estados degradados, todos normales: (1) kind null + motivo sin_score — la entidad nunca fue scoreada, que es la respuesta NORMAL hoy, no un error; (2) score + cobertura SIN components/modos (motivo sin_breakdown, drift_version o error); (3) kind null + motivo error — el bloque falló y el resto del screening sigue siendo válido; (4) `score_fresco`/`delta`/`screening_fresco_at` en null con motivo screening_fallido cuando la pasada en vivo contra sanctions.io no pudo correr — no screeneado NO es limpio. `score` viaja SIEMPRE con `cobertura` (causas evaluadas/total + faltantes): nunca se emite un score sin cobertura, y los scores NO son comparables entre owners con cobertura distinta. Modos: el `score` persistido; `score_sin_screening` (la misma agregación excluyendo la causa de sanciones internacionales — en v1 es igual a `score` en todo el corpus, porque esa causa queda sin_evaluar hasta que existan los veredictos de screening de PAN2-5216/5217); y `score_fresco` + `delta` + `cobertura_fresca` + `components_frescos` + `screening_fresco_at`, re-agregados sobre la pasada en vivo de ESTA respuesta (cacheada hasta 1h) — `components_frescos` es el desglose citado de esa re-agregación (mismas claves/orden que `components`, con la pasada en vivo como `cita` del componente de sanciones) y `cobertura_fresca` su propia cobertura, que en v1 cubre una causa MÁS que la persistida. `score_recomputado` es el desglose persistido re-agregado bajo la config de ESTA imagen, y `delta` = `score_fresco` - `score_recomputado`: el EFECTO PURO DEL SCREENING, comparable porque ambos términos salen del mismo desglose bajo la misma config y solo difieren en el componente sustituido. Que el score persistido esté viejo es una lectura SEPARADA: `score_recomputado` != `score` (legítimo — el score se computa por lotes, `score` es lo que ve el cliente, y acá NO se recomputa). Un `delta` de 0 con un `score_recomputado` por encima de `score` significa entonces: el screening en vivo no cambió nada, el score guardado simplemente está viejo. Solo las 8 listas de sanciones internacionales (SDN, NONSDN, UN, CFSP, UK-SANCTIONS, SSI, CMIC, OFAC-OTHERS) cuentan como hit para esa causa: las listas jurisdiccionales (FATF, HRJ-*, HIGH-RISK-JURISDICTIONS) califican al país, y las listas PEP/CRIME pertenecen a otras causas del score. Los hits de sanciones/PEP son COINCIDENCIAS DE NOMBRE, nunca identidad confirmada: verificar manualmente antes de actuar sobre ellos. No se expone ninguna banda — el consumidor define los cortes.", responses: { "200": { description: screening } } }
  /v5/entities/{id}/naves:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Naves (refs en documentos[]; OCR pendiente pdlayer#800) — DAT-ASSET", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/movable-assets:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Bienes muebles — DAT-ASSET", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/fideicomisos:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Fideicomisos — DAT-BIZ", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/licenses:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Licencias comerciales + personales/idoneidades — DAT-BIZ", responses: { "200": { description: conjuntos de licencias } } }
  /v5/entities/{id}/immigration:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Permisos de trabajo + migraciones + visas + impedimentos (sensible) — DAT-LABOR. Detalle por proceso con timeline (registros[]) vía GET /v5/entities/{id}. En permisos MITRADEL el detalle trae empresa (string crudo de la fuente, truncado a ~32 chars) y empresa_entity_id (pa_own_... de la sociedad patrocinadora resuelta a entidad; null cuando el match no es unívoco — no se inventa). El pivote inverso es GET /v5/entities/{org}/immigration: devuelve también los permisos donde la sociedad figura como patrocinadora (PAN2-5405). Los impedimentos se enlazan a la persona por NOMBRE (la fuente no publica pasaporte ni cédula — PAN2-5415): cada item de impedimentos[] trae match {key, score} con la procedencia del enlace. Es un name-match, no identidad confirmada — verificar manualmente antes de actuar, igual que un hit de sanctions. Y la cobertura del enlace es PARCIAL (hoy ~5% de los impedimentos de la fuente llegan a una persona): impedimentos[] vacío significa 'no lo enlazamos', NO 'no tiene impedimento' — la ausencia no es evidencia; para descartar, buscar por nombre en POST /v5/search/immigration con source=impedimento. Cada hit del search se abre por su id tipado (pa_visaaut_* / pa_imped_*) con GET /v5/entities/{typed_id}, que devuelve el detalle por proceso con timeline (registros[]). Incluye impedimentos de SALIDA a nacionales panameños: la vista no filtra por nacionalidad", responses: { "200": { description: inmigración } } }
  /v5/entities/{id}/planillas:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Planilla estatal (sensible: cédula+salario+pep_score) — DAT-PAYROLL", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/proponente-dossier:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Dossier de proponente — DAT-PROPONENTE", description: "Inteligencia documental sobre la empresa COMO OFERENTE en compras públicas: los campos estructurados que se extrajeron de las propuestas que presentó a licitaciones — beneficiarios finales (accionistas), financieros y referencias bancarias, cumplimiento (paz y salvo / fianza / idoneidad) y capacidad (experiencia / personal / equipo). El vínculo empresa↔documento se resuelve por owner_id al relacionar, así que es una lectura indexada y no un barrido por RUC. `summary.by_group` cuenta el dossier COMPLETO (no la página) y `items` es la página, extracción más reciente primero; `items[].fields` es la salida cruda del extractor de ese tipo de documento, cuya forma depende del doctype.", responses: { "200": { description: "{entity, summary{by_group}, items[], pagination}" } } }
  /v5/entities/{id}/network:
    parameters:
      - { $ref: '#/components/parameters/PathId' }
      - { $ref: '#/components/parameters/PageLimit' }
      - { $ref: '#/components/parameters/PageOffset' }
      - { $ref: '#/components/parameters/DryRunQ' }
      - name: depth
        in: query
        required: false
        description: >-
          Cuántos saltos recorrer. 2 (default) es la respuesta legible: con quién está
          conectada esta entidad, y a través de quién. 3 agrega el tercer salto y es
          mucho más grande — sobre un sujeto real, 303 nodos contra 72. 1 devuelve solo
          la respuesta previa al grafo. La profundidad NO cambia el precio.
        schema: { type: integer, minimum: 1, maximum: 3, default: 2 }
      - name: directors_offset
        in: query
        required: false
        description: Offset dentro de `directors` (el tamaño de página está fijo en 200; ver `directors_pagination`).
        schema: { type: integer, minimum: 0, default: 0 }
      - name: include_family_inference
        in: query
        required: false
        description: >-
          EXPERIMENTAL, opt-in y removible. Agrega sobre el grafo una capa que marca los nodos
          que comparten apellido con el sujeto. Apagada por defecto: sin este parámetro la
          respuesta es byte a byte la de hoy, y la funcionalidad puede retirarse sin perturbar
          el grafo. No construyas una integración estable sobre ella.

          Esto NO es parentesco confirmado, y no existe ninguna fuente de parentesco en el
          sistema — es una coincidencia de apellido, la misma clase de evidencia que una
          coincidencia de nombre en sanciones. Cada candidato lleva QUÉ TAN RARO es cada
          apellido compartido (`uno_en`), que es el número que hace honesto el descargo: un
          sujeto apellidado GONZALEZ saca un falso positivo en ~17,7% de las juntas de cinco
          miembros, contra ~0,2% de un apellido raro. La respuesta lo dice ella misma en
          `family_inference.aviso` cuando el apellido del sujeto es común.

          Dos niveles, ordenados por evidencia y nunca cortados por un umbral:
          `probable_familiar` (dos apellidos compartidos) y `posible_vinculo` (uno). El nivel
          débil NO es ruido para filtrar — es el nivel que atrapa a padres e hijos, que
          normalmente comparten un solo apellido.

          Los nodos marcados llevan `posible_familiar`; los que no lo llevan simplemente no
          fueron marcados, que NO es lo mismo que "evaluados y descartados" — para cobertura
          leer `family_inference`. El bloque `posibles_familiares[]` agrupa las varias grafías
          registrales de una persona en UN solo candidato con `alias`, porque la misma persona
          se inscribe rutinariamente de varias maneras.
        schema: { type: boolean, default: false }
    get:
      summary: "Interlock de directores/accionistas — DAT-NETWORK. memberships+directors más el grafo plano; 2 saltos por defecto, 3 con ?depth=3, mismo precio"
      description: >-
        Todo el grafo es un solo tipo de arista: `(entidad) --cargo--> (organización)`,
        recorrida en ambas direcciones. Una persona natural solo puede ser ORIGEN de
        una arista (las personas no tienen directores); una organización puede ser
        ambas cosas, así que la co-dirección cruzada aparece como dos aristas entre
        los mismos dos nodos.

        La respuesta profunda es PLANA (`nodes` + `edges` únicos), no seccionada: el
        mismo nodo es alcanzable por varios caminos, así que seccionar lo duplicaría.
        La vista seccionada es derivable — las aristas cuyo `to` es la raíz son "mis
        directores", las aristas cuyo `from` es la raíz son "juntas donde participo".
        Las aristas no llevan campo de dirección porque siempre son miembro→organización.

        Aditiva: cada llave de la respuesta histórica conserva su nombre y
        significado; las llaves del grafo son extra.

        El recorrido va 2 niveles salvo que `?depth=3` pida el tercero. No hay
        facturación por nivel: toda profundidad cuesta el mismo SKU plano, así que
        `depth` es una perilla de tamaño, no un escalón de precio.

        La cobertura se declara, nunca se silencia. Todo nodo lleva `expanded` y
        `memberships_count` (ambos siempre presentes, sea cual sea el nodo), y todo
        nodo no expandido lleva `pruned` con la razón:

          * `high_degree` — política. Firmas hub y directores nominales con decenas de
            miles de membresías se reportan *con* `memberships_count` y nunca se
            expanden, a ningún presupuesto. Activa `truncated`.
          * `budget_exhausted` — incidental; una consulta más estrecha puede alcanzarlo.
            Activa `truncated`.
          * `unresolved` — `owner_id IS NULL` en el registro: hoja por construcción,
            se muestra pero no es recorrible. NO activa `truncated`: es un límite del
            dato, no del recorrido.
          * `max_depth` — la profundidad pedida terminó aquí. NO activa `truncated`:
            es el alcance que se pidió, no una falla de cobertura.

        `truncation_reasons` puede contener además `level_rows_capped`, que no es una
        razón a nivel de nodo: significa que un nivel del recorrido alcanzó su tope de
        filas, así que algunos miembros de ese nivel nunca fueron considerados.

        Los ids de nodo de la forma `_unresolved_N` son LOCALES A ESTA RESPUESTA —
        dependen del orden del recorrido y del presupuesto, así que la misma fila
        registral puede ser `_unresolved_2` a depth=2 y `_unresolved_5` a depth=3.
        Nunca persistirlos como identidad.

        `limit`/`offset` paginan la colección `memberships` de la raíz y
        `directors_offset` pagina `directors`; ninguno acota el grafo, que está acotado
        por su propio presupuesto de nodos.

        QUÉ ES UN NOMBRE. El campo de nombre del registro guarda cuatro cosas
        distintas, y cada nodo y cada director dice cuál le tocó en `nombre_kind`:

          * `persona`  — una persona natural.
          * `entidad`  — una empresa o firma de abogados. Los directores corporativos
            (empresas cuyo negocio ES sentarse en juntas) caen acá, y son una señal de
            cumplimiento por derecho propio, no ruido.
          * `lista`    — varias personas en un solo campo, casi siempre bajo
            `Apoderado`. NO se parte: no hay forma de separarlas sin partir mal de vez
            en cuando, y una partición mala firmada por esta API es peor que el texto
            crudo. Se muestra entera para que quien llama decida.
          * `clausula` — una REGLA en vez de un nombre ("EL PRESIDENTE DE LA SOCIEDAD
            SERÁ EL REPRESENTANTE LEGAL…"). Alrededor del 78% de las filas que nunca
            resolvieron a una entidad son esto.

        Cuando el registro pegó el cargo al nombre (`CARLA LOPEZ (TESORERA)`),
        `nombre` conserva la cadena cruda y `nombre_limpio` lleva el nombre solo.
        `is_noise` está DEPRECADO: hoy es apenas `nombre_kind == "clausula"`. Preferir
        `nombre_kind` — el booleano no puede distinguir una cláusula de una empresa de
        una lista.

        ARISTAS DERIVADAS. Una arista con `derivada: true` no viene de una fila
        registral: es algo que resolvimos nosotros. Hoy hay una, cargo
        `representante_legal`, y existe porque el registro inscribe al representante
        legal como CLÁUSULA en vez de como nombre — así que el grafo sabía quién era el
        presidente pero no quién representa a la sociedad, que es la parte que importa
        para KYC.

        Solo se emite cuando el nombre resuelto coincide con un miembro que está
        `activo`. Si no coincide con un miembro activo, no se emite arista y
        `representante_legal_no_verificable` la cuenta: la derivación es un caché y
        puede quedar atrás de un cambio de junta, y en una superficie de KYC abstenerse
        es correcto donde adivinar no lo es. Un conteo distinto de cero significa "para
        esa cantidad de organizaciones no responderíamos por quién las representa", no
        que no tengan representante.
      responses:
        "200":
          description: "{entity, memberships[], directors[], pagination, directors_pagination} (+ depth>1: nodes[], edges[], truncated, truncation_reasons, budget, representante_legal_no_verificable)"
  /v5/entities/{id}/score:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "panadata_score + contadores por superficie — DAT-SCORE", responses: { "200": { description: score } } }
  # Búsqueda facetada cross-entidad (epic #14 fase 3). Facets primero; filas topadas (≤25),
  # max-offset duro 200 (anti-bulk). DAT-*-SEARCH plano por búsqueda (+ base).
  /v5/search/{surface}:
    post:
      summary: "Búsqueda facetada. surface ∈ importaciones|exportaciones|licitaciones|avisos|contraloria|marcas|expedientes|naves|sanctions|real-estate|real-estate-deeds|noticias|planillas|immigration|by-role"
      parameters:
        # Los 14 surfaces de SEARCH_CONFIGS + by-role, que NO está en SEARCH_CONFIGS:
        # es relacional (no OpenSearch) y corta antes de la validación de filtros;
        # del lado MCP lo sirve la tool `search_by_role`, no `search`.
        - { name: surface, in: path, required: true, schema: { type: string, enum: [importaciones, exportaciones, licitaciones, avisos, contraloria, marcas, expedientes, naves, sanctions, real-estate, real-estate-deeds, noticias, planillas, immigration, by-role] } }
        - { $ref: '#/components/parameters/DryRunQ' }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                q: { type: string, description: "consulta full-text" }
                filters: { type: object, description: "específicos por superficie, p. ej. {hs (prefijo: 8703 → todas las 8703.*), procedencia} / {entidad, estado} / {ciuu} / real-estate {codigo_ubicacion, valor_min/_max, superficie_min/_max, edificio, floor, unit_label, price_m2_min/_max, sale_value_min/_max, sale_fecha_min/_max, planta_nivel_min/_max; LEDGER DE GRAVÁMENES — disponibles hoy: acreedor (acreedores vigentes, nombre canónico), acreedor_historico (cualquier gravamen, incl. cancelados), gravamenes_vigentes_min/_max. El grupo a nivel de gravamen gravamen_acreedor | gravamen_estado | gravamen_tipo | gravamen_nivel | gravamen_confianza | gravamen_monto_min/_max | gravamen_fecha_min/_max — TODOS los gravamen_* presentes restringen el MISMO gravamen (acreedor + vigente + monto + fecha sobre una misma hipoteca; el array aplanado daría falsos positivos) — queda DISPONIBLE CUANDO EL ÍNDICE MIGRE AL MAPPING NESTED (flag del gateway REALESTATE_NESTED_GRAVAMENES); mientras tanto esas claves devuelven 400 unknown filters y la lista `valid` no las enseña. PRECIO Y TIER: una búsqueda real-estate con cualquier clave del ledger (acreedor, acreedor_historico, gravamenes_vigentes_*, gravamen_*) cobra DAT-REALESTATE-LEADS (2.50 + base) y requiere tier Pro+ — es la cartera hipotecaria de un banco como lista de leads; sin esas claves cobra DAT-REALESTATE-SEARCH (1.25 + base) y está abierta a todos los tiers. dry_run cotiza el SKU que corresponde a los filtros} / noticias {risk_category, risk_profiles, predicate_crimes, is_risky, risk_score_min} / immigration {source, tipo, estado [DEPRECADO], nacionalidad, abogado, empresa, numero_pasaporte, fecha_resolucion_min/_max, fecha_fin_min/_max, fecha_inicio_min/_max, fecha_ultimo_movimiento_min/_max} (source ∈ migracion|mitradel|visa_autorizada|impedimento. DEPRECADO estado (y su faceta by_estado): se retirará del contrato — es un veredicto derivado cuya semántica se INVIERTE en impedimentos ('resuelto' = el acto se firmó y notificó, o sea el impedimento probablemente ENTRÓ en vigor, no que se levantó) y confunde en visas ('resuelto' sin resultado se lee como aprobada). Ya NO viene en la tarjeta (summary ni detalle); el filtro sigue aceptándose hasta el retiro. OJO: el veredicto SÍ sigue viajando en cada hit de este surface como `derived_data.estado_actual` (la proyección manda derived_data entero) — NO leerlo de ahí: arrastra exactamente la semántica invertida descrita arriba y se retirará del hit junto con el filtro. Para abierto/cerrado usar fecha_fin (solo existe en casos cuyo recorrido cerró) o fecha_inicio/fecha_ultimo_movimiento; el recorrido completo está en registros[] del detalle. Mapa filtro→fuente (cifras medidas contra la réplica el 2026-08-24; un campo solo responde en las fuentes que lo publican en origen; en las demás no existe — NO es un valor vacío): empresa ⇒ solo mitradel; abogado ⇒ solo migracion (impedimentos y visas no lo publican en origen); numero_pasaporte ⇒ mitradel|visa_autorizada (mitradel 309.391/309.564, visas 12.249/12.249; migracion NO lo publica — 0 de 339.861, la clave no existe en origen; impedimentos tampoco lo trae); tipo ⇒ migracion|mitradel|visa_autorizada (impedimentos no lo publica); fecha_resolucion ⇒ solo migracion (cubre el 96% de migracion). OJO numero_resolucion: NO es un filtro aceptado — no está en los filtros de este surface, es un campo del DETALLE (GET /v5/entities/{id}, DAT-LABOR); mandarlo en filters devuelve 400 unknown filters. empresa⊥abogado (combinarlos da 0). Cobertura, no sólo presencia: empresa existe en apenas el 11,4% de mitradel (35.289/309.564), así que filtrar por empresa recorta a ese octavo del corpus MITRADEL — no es un filtro sobre el total. numero_pasaporte es filtro pero NO viene en los hits — el pasaporte se obtiene del detalle GET /v5/entities/{id} (DAT-LABOR). Fechas del timeline (registros[].fecha_inicio/_finalizacion) y fecha_fin tienen granularidad de FECHA: el origen no publica hora del día fiable (PAN2-5413). fecha_fin solo existe en casos cerrados — estado resuelto|cancelado — así que filtrar por fecha_fin excluye todo caso abierto; para ubicar en el tiempo un caso ABIERTO usar fecha_inicio (cuándo entró el trámite) o fecha_ultimo_movimiento (última actividad de la grilla; mismo campo que summary/detalle), que existen esté cerrado o no (PAN2-5408). Mientras el filtro `estado` siga aceptándose: es un enum CERRADO en minúscula: resuelto | en_tramite | cancelado | borrador (`otro` es el escape del enum y NO aparece nunca en datos; cancelado y borrador sólo existen en mitradel). Ojo: `estado` filtra CASE-SENSITIVE (term exacto sobre derived_data.estado_actual) a diferencia de `tipo`, que es case-insensitive — `estado=Resuelto` devuelve 0, `estado=resuelto` devuelve el corpus. `tipo` viene TRUNCADO en origen con dos topes distintos (80 y 50 caracteres) y 17 códigos aparecen bajo ambos ⇒ el mismo trámite se parte en dos buckets; la faceta `by_tipo` es un TOP-40 sobre 254 valores distintos, o sea un ranking y no la enumeración del vocabulario, y como las agregaciones `terms` omiten los ausentes (tipo es null en los 2.481 impedimentos) sum(by_tipo[].count) != total_approx. Frescura: mitradel está CONGELADO — índice y fuente al 2026-05-11, con 298.062 de 309.564 filas (96,3%) sin movimiento desde antes de esa fecha; las facetas de esa fuente describen un corpus estancado. nacionalidad se normaliza a país canónico — 'venezuela', 'venezolana' y 'Venezuela' devuelven lo mismo — y la faceta by_nacionalidad trae un bucket por país en forma visible ('Venezuela', 'España') en vez de uno por variante de escritura de cada fuente. La faceta by_empresa agrega sobre el STRING CRUDO de la fuente (solo mitradel), y las variantes de puntuación/acento y el truncado a ~32 chars parten la misma sociedad en varios buckets — la consecuencia NO es cosmética: EL ORDEN DEL RANKING NO ES CONFIABLE como 'top empleadores' (medido en prod: el empleador #1 reparte sus permisos en 15 variantes y pierde ~20% de su conteo; el #3 real se cae del top completo). Usarla como lista de CANDIDATOS a agregar del lado del cliente, no como top-N publicable; el vínculo firme empresa→entidad es empresa_entity_id del detalle); rangos vía <name>_min/_max" }
                limit: { type: integer, maximum: 25 }
                offset: { type: integer, maximum: 200 }
                owner_id: { $ref: '#/components/schemas/PublicId' }
                cargo: { type: string, description: "by-role only — agente residente / director / …" }
      responses:
        "200": { description: "{facets{}, results[], pagination}. `results[].id` es HETEROGÉNEO por surface: en las surfaces direccionables es el id público tipado (`pa_<type>_<pk>`) y se pega TAL CUAL en `GET /v5/entities/{id}` — importaciones ⇒ pa_imp_*, exportaciones ⇒ pa_exp_*, marcas ⇒ pa_marca_*, naves ⇒ pa_nave_*, real-estate-deeds ⇒ pa_relemento_*, real-estate ⇒ pa_finca_* | pa_ph_* según el índice del hit (finca o propiedad horizontal), immigration ⇒ pa_mig_* | pa_wp_* | pa_visaaut_* | pa_imped_* según `derived_data.fuente`. En las demás (licitaciones, avisos, contraloria, expedientes, sanctions, noticias, planillas) NO hay detalle direccionable: `id` es el PK crudo de Postgres — un STRING en el wire, la forma del `_id` de OpenSearch, NO un integer: tiparlo como integer al generar un cliente desde este spec rompe la deserialización — único sólo dentro de esa surface — no lo trates como id público ni lo compares entre surfaces. Caso borde: si un hit tipado no se puede tipar (valor de discriminador desconocido) sale `id: null` + `raw_id` + `id_type_unresolved` en vez del PK pelado, para que un id sin prefijo nunca se confunda con uno tipado. immigration agrega coverage{}: por fuente, {docs, last_loaded_at, latest_record_at}. Es cobertura de la FUENTE, no del resultado: se calcula sobre todo el corpus, ignorando q y filters, y puede venir cacheada hasta 5 min (PAN2-5408). last_loaded_at = cuándo refrescamos nosotros la fila; latest_record_at = fecha más reciente que publica el timeline de la fuente. Los dos relojes pueden discrepar: una fuente re-scrapeada sin datos nuevos se ve fresca en last_loaded_at y atrasada en latest_record_at — el atraso real es el segundo." }
        "400": { description: "clave de filtro desconocida — {error: 'unknown filters', unknown: [...], valid: [...]}. Una clave de `filters` que la surface no acepta se rechaza; antes se ignoraba en silencio y la búsqueda devolvía TODO sin filtrar. `valid` lista las claves aceptadas: los rangos se aceptan SOLO como `<name>_min`/`<name>_max` — el nombre pelado se rechaza con este mismo 400. Seguir `valid`, que es lo que el validador realmente acepta." }
  /v5/gov-entities:
    get:
      summary: Buscar entidades de gobierno de Panamá por cualquier grafía (tilde, mayúsculas, abreviatura, sigla)
      description: >
        Primero el alias normalizado exacto (`match: alias_exacto|nombre_norm`, score 1.0), después
        similitud pg_trgm sobre todos los alias conocidos (`match: similar`, score ≥ 0.5). Los homónimos
        (dos `OJO DE AGUA` en la misma provincia) vuelven como filas separadas con `match: ambiguo` —
        nunca se fusionan. `q` debe tener al menos 3 caracteres (400 si no); si tras normalizar
        (sin acentos ni puntuación) quedan menos de 3 alfanuméricos la respuesta es `items: []`.
        `tipo`/`provincia` se aplican BEST-EFFORT sobre el top-N de similitud, no se empujan al
        SQL: una lista filtrada puede volver más corta que `limit` aunque existan más matches.
        Solo lectura: nunca crea alias. Facturación: base.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 3 }, description: "nombre de la institución, cualquier variante (≥3 caracteres)" }
        - { name: tipo, in: query, schema: { $ref: '#/components/schemas/GovEntityTipo' }, description: "filtro best-effort dentro del top-N de similitud" }
        - { name: provincia, in: query, schema: { type: string }, description: "1..13 — filtro best-effort dentro del top-N de similitud" }
        - { name: limit, in: query, schema: { type: integer, maximum: 50, default: 20 } }
        - { $ref: '#/components/parameters/DryRunQ' }
      responses:
        '200':
          description: filas rankeadas
          content:
            application/json:
              schema:
                type: object
                properties:
                  query: { type: object }
                  items: { type: array, items: { $ref: '#/components/schemas/GovEntitySummary' } }
        '400': { description: "falta q, o q con menos de 3 caracteres" }
  /v5/gov-entities/{id}:
    get:
      summary: Detalle de entidad de gobierno — tipo, provincia, TODOS sus RUC-NT y cada alias con procedencia
      description: >
        `{id}` debe ser un id `pa_gov_*` (400 si no). Si la entidad fue fusionada en otra
        (`redirect_id`), la respuesta es la entidad superviviente con `redirected_from` y un
        header `Location` — un 200, no un 3xx (convención v5). El mismo id también resuelve por
        `GET /v5/entities/{id}` y `/v5/entities/batch`. Facturación: base.
      parameters:
        - { $ref: '#/components/parameters/PathId' }
        - { $ref: '#/components/parameters/DryRunQ' }
      responses:
        '200': { description: detalle, content: { application/json: { schema: { $ref: '#/components/schemas/GovEntityDetail' } } } }
        '400': { description: no es un id pa_gov }
        '404': { description: "id desconocido, o retirado sin reemplazo" }
  /v5/gov-entities/resolve:
    post:
      summary: Resolver una lista de nombres de instituciones del cliente (≤100) a entidades de gobierno — solo lectura
      description: >
        Un resultado por entrada, en el mismo orden. `alias_exacto|nombre_norm` = identidad; `auto` =
        similitud por encima del umbral de ingesta (se aceptaría, pero el alias NO se crea aquí);
        `ambiguo` = homónimos, ver `alternativas`; `candidato|sin_match` = sin identidad, no se
        inventa nada; `descartado` = no-institución conocida (p. ej. la entidad de prueba del portal
        de compras). Los nombres repetidos se resuelven una vez (igual vuelve una fila por entrada,
        en orden). Facturación: base × N nombres.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nombres]
              properties:
                nombres: { type: array, maxItems: 100, items: { type: string } }
      parameters:
        - { $ref: '#/components/parameters/DryRunQ' }
      responses:
        '200':
          description: resoluciones
          content:
            application/json:
              schema:
                type: object
                properties:
                  results: { type: array, items: { $ref: '#/components/schemas/GovEntityResolution' } }
                  note: { type: string }
        '400': { description: "vacío / >100 / nombres que no son strings" }
  /v5/catalog:
    get:
      summary: Códigos de producto + precios + gramática de ids (gratis)
      parameters:
        - name: country
          in: query
          required: false
          description: >-
            Jurisdicción del catálogo. Cada jurisdicción tiene el suyo y el mismo
            SKU puede mapear a tags distintos por país. Hoy sólo `panama` está
            servido; cualquier otro valor responde 400 (no un catálogo vacío).
          schema: { type: string, default: panama, enum: [panama] }
      responses:
        "200": { description: "{country, base_cost, id_grammar, type_codes[], products{code: {cost, tags[]}}}. `type_codes` es un ARRAY de strings ordenado alfabeticamente: la lista completa de los type codes de la gramatica de ids (`{jur}_{type}_{pk}`) — es lo que hay que usar para armar una allowlist de prefijos; `id_grammar` es solo un ejemplo y no los enumera. No trae descripciones: el significado de cada code esta en el schema `PublicId` y en la documentacion. El registro es append-only: un code nunca se repropone, asi que una allowlist derivada de aca solo puede quedar corta ante codes NUEVOS, nunca equivocada." }
        "400": { description: "country distinto de panama — {error: \"v5 currently supports country=panama; got '<valor>'.\"}" }
