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.
    Ventana de relectura (#218): una lectura por id (`GET /v5/entities/{id}` y sus sub-rutas, salvo
    `/screening` y toda lectura cuyo `include` lleve DAT-SCREEN) que la misma CUENTA ya pagó en las
    últimas 24 h cobra solo el cargo base — misma ruta, mismos códigos, misma página; cualquier
    llave de la cuenta. Los datos se leen de nuevo (actuales, no de caché) y la respuesta lleva
    `X-Panadata-Already-Purchased: <primera compra, ISO 8601>`. El dry_run de esa lectura cotiza el
    cargo base y agrega `already_purchased`.
    Toda ruta facturable puede rechazar a una llave con saldo (antes de correr, a la cotización
    completa) con **402 `insufficient_credits`** (InsufficientCredits: balance, price, shortfall,
    upgrade_url, y `hint` → DAT-FREE cuando el saldo todavía la cubre) o **429
    `spending_limit_exceeded`** (SpendingLimitExceeded: el tope mensual que fijó el dueño de la
    cuenta — limit, consumed, price, resets_at, settings_url, `Retry-After`). Ninguno se cobra.
    `error` conserva su string de siempre en los dos (`insufficient credits`,
    `spending_limit_exceeded`); usar `code`.

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

security:
  - bearerKey: []

# Niveles de acceso (#125, diseño api-dat-free-2026-09 §1b). `public` (sin llave) llega con #126.
#   paid   llave con saldo: identificadores completos, todo el catálogo.
#   free   llave con billetera en cero: sólo CHK-BIN, DAT-RESOLVE y DAT-FREE; `resolve` con
#          `disambiguator` y `summary.ruc_no_dv` ENMASCARADOS (`8-***-2264`, `155***-2-2016`);
#          `GET /v5/entities/{id}` sin `include` devuelve la ficha DAT-FREE; lote ≤10 filas.
#   public sin llave (nivel 0, #126): ficha DAT-FREE reducida, resolve enmascarado, una entidad
#          por llamada. Sólo POST /v5/resolve y GET /v5/entities/{id} (`security: [{}]` abajo),
#          detrás de PUBLIC_TIER_ENABLED; presupuesto global diario (429 public_budget_exhausted);
#          cabeceras X-Panadata-Tier / X-Panadata-Public-Budget-Remaining.
#   sandbox llave de prueba (`billing_lane: "sandbox"`, `pk_test_…`): nunca paga y NUNCA lee datos
#          reales. GET /v5/entities/{id} lee el dataset sintético (pa_own_2100000001–999, DAT-CORE
#          o sin include, respuestas guardadas con `mode: sandbox`): fuera de ese rango 404 `not_in_sandbox`, otro código 403
#          `sandbox_code_not_ready`. Toda otra ruta responde 403 `sandbox_route_not_ready`
#          (SandboxRouteNotReady); `dry_run` cotiza el precio real sin cobro.
#          Las respuestas de require_credits / require_auth llevan `X-Panadata-Mode: sandbox`.
#
# Paridad MCP ↔ API (#128, regla 5 del diseño): todo lo que hace el MCP lo hace el API y al
# revés, en el mismo nivel y con la misma respuesta. Cada path item lleva:
#   x-mcp-tool          la tool de pd-api-mcp que lo envuelve (search_by_role para
#                       /search/by-role → x-mcp-tool-by-surface; get_entity_records lleva
#                       x-mcp-record-type). Desde #117 no queda tool sin ruta v5:
#                       request_entity_update y request_real_estate_update comparten
#                       POST /v5/entities/{id}/update (x-mcp-tool con las dos, separadas por coma).
#   x-curl-example      el `curl` equivalente por nivel de acceso (public | free | paid);
#                       `curl: null` + `note` = ese nivel no puede (y con qué código responde).
# El test de contrato que compara tool ↔ ruta ↔ respuesta por nivel vive en pd-api-mcp.

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), ur|aur
        (update requests — ur = re-scrape de un owner en la lane de plataforma, aur =
        re-scrape de un activo en la lane automática; dos lanes, dos bases de datos, así que
        el entero solo es ambiguo).
        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|ur|aur)_[0-9]+$'
      examples: ["pa_own_42", "pa_finca_153", "pa_entrada_88", "pa_gov_7", "pa_ur_310"]
    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, minimum: 0.5, maximum: 1, description: "0..1 — los candidatos vienen ordenados por confidence DESC (#140); nunca se emite uno por debajo de 0.5 (#139)" }
        match: { type: string, enum: [high, medium, low] }
        matched_on:
          type: array
          items: { type: string, description: "name | ruc | cedula — el identificador que lleva el CANDIDATO: una persona que casó por su cédula reporta `cedula` aunque quien llama haya mandado los dígitos como `ruc` (#141)" }
        disambiguator: { type: string, nullable: true, description: "ruc/cédula del candidato; dos filas con el mismo nombre e identificadores DISTINTOS son dos candidatos, nunca se fusionan" }
        duplicates: { type: array, items: { $ref: '#/components/schemas/PublicId' }, description: "filas de la misma identidad plegadas en este candidato (solo presente cuando las hubo)" }
        summary: { type: object }
    ResolveRow:
      type: object
      description: "una entrada de POST /v5/resolve/batch — al menos uno de name | names | ruc | cedula"
      properties:
        ref: { type: string, description: "llave de fila de quien llama, devuelta tal cual (por defecto, el índice de la fila)" }
        name: { type: string }
        names: { type: array, items: { type: string }, maxItems: 5, description: "grafías del MISMO nombre; un solo cargo" }
        ruc: { type: string, description: "cualquier grafía: `8 777 2264`, `45008-12-298240 DV 32`, `e-8-30749` se normalizan a la forma canónica con guiones antes de la búsqueda (#139)" }
        cedula: { type: string, description: "misma normalización que ruc" }
    ResolveBatchItem:
      type: object
      properties:
        ref: { description: "tal como se envió, o el índice de la fila" }
        input: { type: object, description: "la fila tal como se envió, sin ref" }
        outcome: { type: string, enum: [resolved_exact, ambiguous, unresolved, invalid], description: "resolved_exact = el identificador dio con UN registro, o un único match por nombre ≥0.95; ambiguous = varios homónimos exactos, un match ≥0.60 por confirmar, o el identificador dio con una empresa (RUC) Y una persona (cédula) — #141, decidido sobre la lista completa antes del corte de `top`; unresolved = nada ≥0.60; invalid = fila inutilizable o un identificador que no se puede normalizar (`identificador no reconocido: <input>`) — NO se cobra, NO se cuenta (#139)" }
        exact_homonyms: { type: integer, nullable: true }
        candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
        note: { type: string, description: "solo cuando el identificador casó con más de un registro (#141): cuántos y de qué tipos — elegir por summary.type / matched_on" }
        error: { type: string, description: "solo en filas inválidas; la fila no se cobra" }
    FreeTierLimit:
      type: object
      description: "Body del 429 de una llave gratuita cuya petición no cabe en el cupo del período (#138). `used + requested > limit`: el mensaje dice «would exceed <window>'s remaining N»; `used >= limit`: «limit reached». Viene con `Retry-After` (segundos hasta reset_at) y las cabeceras X-Panadata-Free-*."
      properties:
        error: { type: string }
        code: { type: string, enum: [free_tier_limit] }
        period: { type: string, enum: [day, month] }
        limit: { type: integer }
        used: { type: integer }
        remaining: { type: integer }
        requested: { type: integer, description: "entidades que esta petición habría contado (filas cobrables del lote, 1 en una llamada simple)" }
        reset_at: { type: string, format: date-time, description: "la próxima medianoche UTC (day) / el primer día del mes siguiente (month)" }
        upgrade_url: { type: string, description: "UPGRADE_URL — la página de compra (recargas y planes, de Sandbox en adelante; default https://platform.panadata.net/facturacion, #142)" }
    InsufficientCredits:
      type: object
      description: "Cuerpo del 402 de una llave CON saldo que no alcanza a pagar esta llamada — antes del handler (el pre-chequeo a la cotización completa) o, rara vez, después (el débito condicional perdió la carrera con otro request; ahí el saldo se relee). No se cobra. Los créditos van como string. `balance` y `shortfall` se omiten si la relectura posterior al handler falla (nunca se adivinan); si esa relectura encuentra que el saldo ya cubre `price` (entró una recarga en el medio), se omite `shortfall` y el mensaje dice que se reintente. Una llave SIN saldo normalmente recibe en su lugar los rechazos del nivel gratuito (403 free_tier_code_locked / 429 free_tier_limit): el nivel se decide al autorizar la llave, así que una billetera que otra request vacía después de ese punto recibe este 402 con `balance: \"0.00\"`."
      required: [error, code, price, upgrade_url, message]
      properties:
        error: { type: string, enum: ["insufficient credits"], description: "string de siempre, sin cambios — usar `code`" }
        code: { type: string, enum: [insufficient_credits] }
        message: { type: string, description: "qué hacer, en inglés: el precio, el saldo y lo que falta, el enlace de compra y «DAT-FREE still works» cuando es cierto" }
        balance: { type: string, description: "saldo de la billetera ahora, p. ej. \"0.90\"" }
        price: { type: string, description: "créditos que cuesta esta llamada (la cotización completa antes del handler; lo que intentó el débito después)" }
        shortfall: { type: string, description: "price − balance" }
        hint: { type: string, enum: ["GET /v5/entities/{id}?include=DAT-FREE"], description: "solo cuando el saldo todavía cubre una lectura DAT-FREE con esta llave: la ficha de señales sigue respondiendo" }
        upgrade_url: { type: string, description: "UPGRADE_URL — la página de compra (default https://platform.panadata.net/facturacion)" }
      example:
        error: "insufficient credits"
        code: insufficient_credits
        price: "1.56"
        balance: "0.90"
        shortfall: "0.66"
        hint: "GET /v5/entities/{id}?include=DAT-FREE"
        upgrade_url: "https://platform.panadata.net/facturacion"
        message: "Insufficient credits: this call costs 1.56 and the balance is 0.90 (0.66 short). Add credits at https://platform.panadata.net/facturacion. DAT-FREE still works: GET /v5/entities/{id}?include=DAT-FREE returns the signals card (booleans, ranges and the list of codes with data)."
    SpendingLimitExceeded:
      type: object
      description: "Cuerpo del 429 cuando esta llamada llevaría el gasto del mes por encima del tope que fijó el dueño de la cuenta (`spending_limits`). No se cobra. Viene con `Retry-After` (segundos hasta resets_at). Los créditos van como string; `limit` y `consumed` son del período actual (un período vencido cuenta desde 0) y se omiten si la fila no se pudo releer después del handler."
      required: [error, code, message, resets_at, settings_url]
      properties:
        error: { type: string, enum: [spending_limit_exceeded], description: "string de siempre, sin cambios" }
        code: { type: string, enum: [spending_limit_exceeded] }
        message: { type: string, description: "empieza con «Monthly spending limit exceeded»; después el precio, lo usado del tope, el reinicio y el enlace a la configuración" }
        price: { type: string }
        limit: { type: string }
        consumed: { type: string }
        resets_at: { type: string, format: date-time, description: "el primer día del mes siguiente, UTC" }
        settings_url: { type: string, description: "SPENDING_LIMIT_URL — donde el dueño sube o quita el tope (default https://platform.panadata.net/facturacion#limite)" }
      example:
        error: spending_limit_exceeded
        code: spending_limit_exceeded
        price: "3.50"
        limit: "10.00"
        consumed: "8.50"
        message: "Monthly spending limit exceeded: this call costs 3.50 and 8.50 of this account's 10.00 monthly limit is already used. It resets at 2026-11-01T00:00:00+00:00; to continue sooner, raise the limit at https://platform.panadata.net/facturacion#limite."
        resets_at: "2026-11-01T00:00:00+00:00"
        settings_url: "https://platform.panadata.net/facturacion#limite"
    SandboxRefusal:
      type: object
      description: "Cuerpo del 403 para una llave del carril sandbox (modo de prueba del API, `billing_lane: \"sandbox\"`) en una ruta que todavía no lee el dataset sintético. Las llaves sandbox nunca se cobran y nunca leen datos reales, así que la petición se rechaza antes de que corra la ruta. Un `?dry_run=1` de v5 sigue respondiendo el precio real."
      required: [error, code, mode]
      properties:
        error: { type: string }
        code: { type: string, enum: [sandbox_route_not_ready] }
        mode: { type: string, enum: [sandbox] }
    BackendError:
      type: object
      description: "Body de error de una búsqueda que OpenSearch no respondió completa o útilmente (#155, #156). Tres códigos, tres significados: `search_backend_error` (502, retryable: true, con `Retry-After`) — OpenSearch estaba caído, se agotó el tiempo, descartó carga (429 / es_rejected_execution) o respondió de forma PARCIAL (shards fallidos, timed_out o — en /v5/search — cero shards porque falta cada índice detrás de la surface); `invalid_search_query` (400, retryable: false) — OpenSearch rechazó la forma de la consulta, típicamente un valor de filtro del tipo equivocado; `internal_error` (500, retryable: false) — el gateway no pudo leer una respuesta buena (un bug). Ninguno se cobra ni cuenta contra el cupo gratuito. Nunca un 200 con cero hits: eso se leía como \"no existe\" y se cobraba."
      required: [error, code, retryable]
      properties:
        error: { type: string }
        code: { type: string, enum: [search_backend_error, invalid_search_query, internal_error] }
        retryable: { type: boolean, description: "true solo para search_backend_error: reintentar tras `Retry-After` segundos. false: la misma petición fallará igual" }
    Bucket:
      type: string
      enum: ["0", "1-2", "3-9", "10+"]
      description: "rango, nunca el conteo exacto (#125 §3)"
    DatFree:
      type: object
      description: >
        DAT-FREE (#125) — la ficha de señales: responde qué hay detrás de una entidad sin entregar
        registros. Todo campo es booleano, rango o año; nunca un nombre de tercero, un monto,
        una fecha completa, un identificador ni un número que revele la magnitud del registro (sin
        `score`: `panadata_score` es la suma cruda de los contadores y es lo que vende DAT-SCORE).
        Cada señal apunta al SKU pagado que tiene los registros. Persona natural: sin `directores`,
        `contrata_con_estado`, `comercio_exterior`. Sin llave (nivel 0) sólo viajan id, country,
        nombre, nivel, existe, vigente, suspendida, tipo, anio_constitucion, codigos_con_datos,
        actualizado y siguiente_paso. Un booleano `null` = la entidad no tiene mapa de presencia
        (no "false").
      properties:
        id: { $ref: '#/components/schemas/PublicId' }
        country: { type: string }
        nombre: { type: string, nullable: true }
        nivel: { type: string, enum: [DAT-FREE] }
        existe: { type: boolean }
        vigente: { type: boolean, nullable: true, description: "estatus registral VIGENTE → DAT-CORE" }
        suspendida: { type: boolean, nullable: true, description: "→ DAT-REGISTRY" }
        tipo: { type: string, enum: [persona, organizacion, fundacion] }
        anio_constitucion: { type: integer, nullable: true, description: "sólo el año → DAT-CORE" }
        directores: { $ref: '#/components/schemas/Bucket', nullable: true, description: "directores activos → DAT-CORE (nombres y cargos)" }
        tiene_judicial: { type: boolean, nullable: true, description: "expedientes > 0 → DAT-RISK" }
        moroso_css: { type: boolean, nullable: true, description: "→ DAT-RISK" }
        contrata_con_estado: { type: boolean, nullable: true, description: "licitaciones ∪ contraloría > 0 → DAT-PROC / DAT-CGR" }
        en_planilla_estatal: { type: boolean, nullable: true, description: "→ DAT-PAYROLL" }
        inmuebles: { $ref: '#/components/schemas/Bucket', nullable: true, description: "fincas + PH → DAT-REALESTATE" }
        comercio_exterior: { type: boolean, nullable: true, description: "importaciones ∪ exportaciones > 0 → DAT-TRADE" }
        noticias: { type: boolean, nullable: true, description: "→ DAT-NEWS" }
        codigos_con_datos: { type: array, items: { type: string }, nullable: true, description: "el menú: códigos DAT por superficie que devolverían datos (= presencia.available_codes sin DAT-ALL); null sin mapa de presencia — `[]` diría que no hay nada que comprar" }
        actualizado: { type: string, nullable: true, description: "YYYY-MM de la última actualización → UPD-LIVE" }
        siguiente_paso:
          type: object
          properties:
            para_ver_los_registros: { type: string, description: "PRICING_URL — la hoja de precios pública (default https://platform.panadata.net/docs/precios, #142)" }
            tokens_dat_core: { type: number, nullable: true, description: "créditos de ?include=DAT-CORE (SKU + base)" }
    # #135 — listas inline del detalle resumidas. Sólo aparece cuando la lista se cortó.
    InlineMeta:
      type: object
      description: >-
        Hermano de una sección-lista de GET /v5/entities/{id} que se cortó al límite inline
        (`DETAIL_INLINE_LIMIT`, default 10; `?inline_limit=1..100`). La llave se llama `<section>_meta`
        (`importaciones_meta`, `documentos_contraloria_meta`, …). Ausente cuando la lista cabe.
      required: [total, inline, truncated, next]
      properties:
        total: { type: [integer, "null"], description: "filas detrás de la lista. En `fincas_meta`/`propiedades_meta` son las tenencias VIGENTES que sirve el detalle (#149): cuando la lista no se cortó es igual a las filas enviadas, y la tenencia pasada se reporta aparte en `historicas`. En toda otra sección, presencia.counters; null cuando se desconoce" }
        inline: { type: integer, description: "filas incluidas inline en esta respuesta (el límite)" }
        truncated: { type: boolean, description: "true cuando la lista se cortó. `fincas_meta` / `propiedades_meta` también aparecen sin cortar (false) en el modo por defecto `summary=compact` (#144), para llevar `detail`" }
        next: { type: [string, "null"], description: "sub-recurso paginado con el resto, p. ej. /v5/entities/{id}/importaciones?offset=10&limit=50; null cuando la sección no tiene sub-recurso (noticias_globales, entity_events) o cuando no se cortó" }
        compact: { type: boolean, description: "#144 — solo en `fincas_meta` / `propiedades_meta` con `summary=compact`: las filas son `RealEstateGeneral` (generales), no el ítem v4" }
        historicas: { type: integer, description: "#149 — solo en `fincas_meta` / `propiedades_meta`, cuando es > 0 y la lista no se cortó: inmuebles que la entidad TUVO y ya no tiene (presencia cuenta toda la relación owner↔propiedad, vigente o no). Explica un menú que anuncia inmuebles que la lista no trae" }
        historical_detail: { type: string, description: "#149 — `/v5/entities/{id}/real-estate?include_historical=1`: el portafolio incluyendo la tenencia pasada, cada ítem con `activo`. Solo junto a `historicas`" }
        detail: { type: string, description: "#144 — solo en `fincas_meta` / `propiedades_meta`: `/v5/entities/{id}/real-estate` (DAT-REALESTATE-INTEL); su `?view=full` trae el historial completo de gravámenes / compraventas (deudores, acreedores, montos)" }
    # #144 — detalle compacto (default `summary=compact`): aduanas por año e inmuebles en generales.
    TradeYearEntry:
      type: object
      description: "Un año de `imports_summary` / `exports_summary` en el detalle compacto (#144)."
      required: [valor_cif, total_shipments, top_products]
      properties:
        valor_cif: { type: number, description: "Σ del valor CIF de los embarques del año, 2 decimales" }
        total_shipments: { type: integer }
        top_products:
          type: array
          maxItems: 5
          description: "las 5 fracciones arancelarias con mayor valor CIF del año, sumadas sobre sus 12 meses; la mayor primero"
          items:
            type: object
            required: [fraccion, shipments, valor_cif]
            properties:
              fraccion: { type: string, description: "fracción arancelaria tal como se declaró (p. ej. 0702.00.00)" }
              shipments: { type: integer }
              valor_cif: { type: number }
    TradeYearlySummary:
      type: [object, "null"]
      description: >-
        `imports_summary` / `exports_summary` de GET /v5/entities/{id} en el modo por defecto
        `summary=compact` (#144): los totales por año más las 5 fracciones principales de cada año.
        `years` lista los años presentes (ascendente); cada año va bajo su propia llave, como en el
        resumen guardado menos `monthly`. El desglose completo mes × fracción × país está en
        GET /v5/entities/{id}/trade-profile (DAT-TRADE-INTEL) y en el detalle con `?summary=full`.
        null cuando la entidad no tiene registros aduaneros de ese lado.
      required: [years]
      properties:
        years: { type: array, items: { type: string, pattern: "^(19|20)[0-9]{2}$" } }
      patternProperties:
        "^(19|20)[0-9]{2}$": { $ref: '#/components/schemas/TradeYearEntry' }
    RealEstateGeneral:
      type: object
      description: >-
        Una fila de `fincas` / `propiedades` en el detalle por defecto `summary=compact` (#144): los
        generales del inmueble. El historial de gravámenes y compraventas (hipotecas, compraventas,
        record_events, propietarios, entradas — deudores, acreedores, montos) NO está aquí: es
        DAT-REALESTATE-INTEL, GET /v5/entities/{id}/real-estate (`fincas_meta.detail`), o el detalle
        con `?summary=full`.
      required: [id, ficha, codigo_ubicacion, ubicacion, superficie, valor, gravamenes_vigentes_count, acreedores_vigentes, ultima_compraventa]
      properties:
        id: { type: string, description: "id tipado: pa_finca_<pk> | pa_ph_<pk> (el pk crudo cuando se desconoce el subtipo)" }
        ficha: { type: [string, "null"], description: "número de finca del Registro Público" }
        codigo_ubicacion: { type: [string, integer, "null"] }
        ubicacion: { type: [string, "null"], description: "el texto `domicilio` del registro (corregimiento / distrito / provincia)" }
        edificio: { type: [string, "null"], description: "nombre del edificio (solo propiedades)" }
        superficie: { type: [string, number, "null"] }
        valor: { type: [number, string, "null"], description: "valor registral" }
        gravamenes_vigentes_count: { type: [integer, "null"], description: "gravámenes vigentes según el ledger de gravámenes de pdlayer (PAN2-4946: constituciones menos cancelaciones EMPAREJADAS); null cuando el ledger no está calculado para este inmueble (desconocido, no cero)" }
        acreedores_vigentes: { type: [array, "null"], items: { type: string }, description: "nombres normalizados de los acreedores de los gravámenes vigentes; null cuando se desconoce" }
        ultima_compraventa:
          type: [object, "null"]
          description: "la escritura de compraventa más reciente del folio; null cuando no hay"
          properties:
            fecha: { type: [string, "null"] }
            valor: { type: [number, string, "null"], description: "valor_traspaso leído de la escritura; null cuando el OCR no lo leyó" }
    CssMorosoMencion:
      type: object
      description: >-
        Una mención en el listado mensual de empleadores morosos de la CSS (Caja de Seguro
        Social): la fila propia del empleador listado más la publicación en que apareció. Ítem de
        `css_moroso_menciones` (GET /v5/entities/{id}, DAT-RISK) y de
        GET /v5/entities/{id}/css-morosos. Un ítem por fila de empleador, así que un owner puede
        traer dos ítems del mismo mes (`id` se repite, `empleador_id` no). Publicación más
        reciente primero. Los montos son los declarados en el listado público a su fecha de
        publicación: una referencia, no una deuda actual confirmada. Los campos del empleador
        vienen null cuando la extracción del listado no los leyó (desconocido, no cero): solo los
        listados de junio-2025, diciembre-2025 y junio-2026 traen montos; los anteriores traen
        solo nombre/ruc. Nunca se devuelve el texto OCR del listado (~5M caracteres, todo el
        país): usa `pdf_url`.
      properties:
        id: { type: [integer, "null"], description: "PK de la publicación (panama_css_morosos)" }
        sid: { type: [string, "null"], description: "id de fuente de la publicación, p. ej. listado-empleadores-morosos_junio-2026" }
        titulo_documento: { type: [string, "null"], description: "período de la publicación, p. ej. junio-2026" }
        fecha_publicacion: { type: [string, "null"] }
        pdf_url: { type: [string, "null"], description: "PDF del listado mensual completo" }
        empleador_id: { type: [integer, "null"], description: "PK de la fila del empleador (panama_css_morosos_entities); única por mención" }
        nombre: { type: [string, "null"], description: "nombre del empleador tal como se extrajo del listado (en minúsculas)" }
        ruc: { type: [string, "null"], description: "RUC o cédula tal como aparece en el listado" }
        tipo: { type: [string, "null"], description: "persona | organizacion | desconocido, según el extractor" }
        numero_patronal: { type: [string, "null"] }
        actividad_economica: { type: [string, "null"] }
        representante_legal: { type: [string, "null"], description: "representante legal tal como aparece en el listado" }
        monto_adeudado: { type: [number, "null"], description: "total adeudado según el listado (B/.)" }
        cantidad_planillas: { type: [integer, "null"], description: "planillas en mora según el listado" }
        condicion: { type: [string, "null"], description: "código de condición de una letra del listado (M, J, C, G, E), tal como aparece" }
    ContraloriaSummary:
      type: object
      description: "Totales de `documentos_contraloria` por institución y por año, calculados sobre las filas que cargó el detalle (≤100) — `basis: inline_rows`, nunca el universo completo (`total`)."
      properties:
        basis: { type: string, const: inline_rows }
        rows: { type: integer }
        total: { type: [integer, "null"] }
        monto_total: { type: number }
        sin_monto: { type: integer, description: "filas cuyo monto no se pudo parsear" }
        por_institucion:
          type: array
          items: { type: object, properties: { institucion: { type: [string, "null"] }, count: { type: integer }, monto: { type: number }, sin_monto: { type: integer } } }
        por_anio:
          type: array
          items: { type: object, properties: { anio: { type: [integer, "null"] }, count: { type: integer }, monto: { type: number }, sin_monto: { type: integer } } }
    NetworkGraph:
      type: [object, "null"]
      description: >-
        La sección `network` de GET /v5/entities/{id}?include=DAT-NETWORK (#135): el MISMO grafo
        que devuelve GET /v5/entities/{id}/network con sus defaults (depth 2, memberships 50/0,
        directors 200/0, presupuesto de 500 nodos), sin el envoltorio de la ruta (`entity`,
        `pagination`, `directors_pagination`). `null` en ids que no son `pa_own_*` (activos,
        expedientes, migración, gov) y cuando no se pudo construir el grafo — entonces DAT-NETWORK
        no se cobra.
      properties:
        memberships: { type: array, items: { type: object } }
        directors: { type: array, items: { type: object } }
        nodes: { type: array, items: { type: object } }
        edges: { type: array, items: { type: object } }
        depth: { type: integer }
        truncated: { type: boolean }
        truncation_reasons: { type: array, items: { type: string } }
        budget: { type: object, properties: { nodes: { type: integer }, used: { type: integer }, exhausted: { type: boolean } } }
        representante_legal_no_verificable: { type: integer }
    DryRun:
      type: object
      description: "Body 200 de cualquier `?dry_run=1`. Llaves con saldo: la cotización en créditos. Llave gratuita (#142): `would_charge` \"0\" / `credits` 0 — esa llave nunca paga — más `free_tier` con lo que la llamada real le haría al tope de entidades. Una llave gratuita que cotiza un código candado conserva el precio en créditos (lo que costaría con créditos) y `free_tier.locked`."
      properties:
        dry_run: { type: boolean }
        would_charge: { type: string }
        credits: { type: number }
        product_codes: { type: array, items: { type: string } }
        free_tier: { $ref: '#/components/schemas/FreeTierQuote' }
        already_purchased:
          type: object
          description: "llave con saldo, ventana de relectura (#218): esta misma lectura se compró en las últimas 24 h, así que `would_charge` / `credits` son el cargo base"
          properties:
            purchased_at: { type: string, format: date-time }
            expires_at: { type: string, format: date-time }
    FreeTierQuote:
      type: object
      description: "solo llave gratuita (#142). Leer los contadores no los incrementa: una cotización nunca cuenta. `budget: daily` — el tope de entidades (30/día · 500/mes; 100 · 1.000 con dominio corporativo): las cifras de primer nivel son las de hoy, `month` las del mes, y `would_exceed` es true cuando CUALQUIERA de los dos no alcanza (exactamente cuando la llamada real recibe 429 free_tier_limit). `budget: estimator` — resolve/batch con with_presence: no toca el tope de entidades; `counts_as` son filas del estimador (5.000/mes) y agotarlas es 402 estimator_rows_exhausted. `locked: true` — un código fuera del nivel gratuito: la llamada real es 403 free_tier_code_locked y no cuenta nada. Cuando los contadores no se pueden leer, used/remaining/would_exceed vienen null."
      properties:
        budget: { type: string, nullable: true, enum: [daily, estimator, null] }
        counts_as: { type: integer, description: "entidades (daily) o filas (estimator) que contaría la llamada real: filas cobrables de un lote (las filas inválidas cuentan 0), ids de entities/batch, 1 en los demás casos" }
        period: { type: string, enum: [day, month], description: "período de used/limit/remaining: day para daily, month para estimator" }
        used: { type: integer, nullable: true }
        limit: { type: integer }
        remaining: { type: integer, nullable: true }
        month:
          type: object
          description: "solo presupuesto daily — las cifras de este mes (mes UTC)"
          properties:
            used: { type: integer, nullable: true }
            limit: { type: integer }
            remaining: { type: integer, nullable: true }
        would_exceed: { type: boolean, nullable: true, description: "la llamada real sería rechazada (429, o 402 para el estimador); null cuando no se pudieron leer los contadores" }
        exceeded_period: { type: string, enum: [day, month], description: "solo cuando would_exceed" }
        would_status: { type: integer, enum: [429, 402, 403], description: "solo cuando la llamada real sería rechazada" }
        code: { type: string, enum: [free_tier_limit, estimator_rows_exhausted, free_tier_code_locked], description: "solo cuando la llamada real sería rechazada — el `code` que traería su body" }
        reset_at: { type: string, format: date-time, description: "solo cuando would_exceed — la próxima medianoche UTC (day) / el primer día del mes siguiente (month)" }
        locked: { type: boolean, description: "solo en la cotización de un código fuera del nivel gratuito" }
        locked_codes: { type: array, items: { type: string }, description: "solo con locked" }
        upgrade_url: { type: string, description: "UPGRADE_URL — la página de compra (default https://platform.panadata.net/facturacion)" }
  responses:
    InsufficientCredits:
      description: "`insufficient_credits` — la billetera no cubre esta llamada (ver InsufficientCredits). No se cobra."
      content: { application/json: { schema: { $ref: '#/components/schemas/InsufficientCredits' } } }
    SpendingLimitExceeded:
      description: "`spending_limit_exceeded` — el tope de gasto mensual de la cuenta (ver SpendingLimitExceeded). No se cobra."
      headers:
        Retry-After: { schema: { type: integer }, description: "segundos hasta resets_at" }
      content: { application/json: { schema: { $ref: '#/components/schemas/SpendingLimitExceeded' } } }
    SandboxRouteNotReady:
      description: "`sandbox_route_not_ready` — la llave es de prueba (carril sandbox) y esta ruta todavía no sirve datos sintéticos. No se cobra. Usa `?dry_run=1` para ver el precio real, o una llave en vivo para datos reales."
      headers:
        X-Panadata-Mode: { schema: { type: string, enum: [sandbox] }, description: "en toda respuesta que una llave del carril sandbox recibe de una ruta cobrada" }
      content: { application/json: { schema: { $ref: '#/components/schemas/SandboxRefusal' } } }
    SearchBackendError:
      description: "`search_backend_error` — OpenSearch no respondió completo: caído, tiempo agotado, descarte de carga (429 / es_rejected_execution) o una respuesta parcial (shards fallidos / timed_out). Nunca un 200 con cero hits (eso se leía como \"no existe\" y se cobraba); no se cobra ni cuenta contra el cupo gratuito — reintentar tras `Retry-After`."
      headers:
        Retry-After: { schema: { type: integer }, description: "segundos a esperar antes de reintentar (5)" }
      content: { application/json: { schema: { $ref: '#/components/schemas/BackendError' } } }
    SearchInternalError:
      description: "`internal_error` {error, code, retryable: false} — el gateway no pudo leer la respuesta de OpenSearch (un bug de nuestro lado). No se cobra; reintentar la misma petición no ayuda."
      content: { application/json: { schema: { $ref: '#/components/schemas/BackendError' } } }
  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)" }
    # #136: dry_run es gratis e informativo en cualquier nivel (public/free/paid): cotiza cualquier
    # código sin cobrar ni consumir contadores; el candado free_tier_code_locked sólo aplica a la
    # llamada real. No levanta los topes de filas del lote ni las rutas candadas del nivel 0.
    DryRunQ: { name: dry_run, in: query, schema: { type: boolean }, description: "previsualiza el cargo — gratis en todo nivel de acceso (quien llama con llave gratuita o sin llave puede cotizar códigos pagos); no cobra nada ni consume contadores. Body: DryRun; una llave gratuita recibe `would_charge` \"0\" más un bloque `free_tier` (FreeTierQuote: counts_as, used/limit/remaining, month, would_exceed, budget daily|estimator — #142)" }

paths:
  /v5/entities:
    x-mcp-tool: search_entities
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities?q=Cerveceria%20Nacional'"
      - level: free
        curl: null
        note: "403 free_tier_code_locked (DAT-SEARCH): las búsquedas no están en el nivel gratuito — usar POST /v5/resolve"
    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 }
        "400": { description: "ni q ni ruc; `invalid_search_query` (BackendError, retryable: false) — OpenSearch rechazó la forma de la consulta (#156)" }
        "500": { $ref: '#/components/responses/SearchInternalError' }
        "502": { $ref: '#/components/responses/SearchBackendError' }
  /v5/resolve:
    x-mcp-tool: resolve_entity
    x-curl-example:
      - level: public
        curl: "curl -X POST -H 'content-type: application/json' -d '{\"name\":\"Cerveceria Nacional\"}' https://api.panadata.net/v5/resolve"
        note: "sin cabecera: candidatos con disambiguator 155***-2-2016; X-Panadata-Tier: public"
      - level: free
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave gratuita>' -H 'content-type: application/json' -d '{\"name\":\"Luis Gomez\"}' https://api.panadata.net/v5/resolve"
        note: "ids enmascarados (8-***-2264), exact_homonyms intacto"
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"cedula\":\"8-777-2264\"}' https://api.panadata.net/v5/resolve"
        note: "identificador completo; DAT-RESOLVE 0.06 + base"
    post:
      parameters:
        - { $ref: '#/components/parameters/DryRunQ' }
      summary: "#4 — resolver nombre/ruc/cédula → gids candidatos rankeados (probabilístico)"
      # Nivel 0 (#126): también sin Authorization (cuando PUBLIC_TIER_ENABLED), con ids enmascarados.
      security: [{}, {bearerKey: []}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                names: { type: array, items: { type: string }, maxItems: 5, description: "hasta 5 grafías del MISMO nombre ('Luis A. Gomez', 'Gomez Perez, Luis'); una consulta, un cargo. Usar en lugar de `name`." }
                ruc: { type: string, description: "cualquier grafía — espacios, un ` DV nn` al final, letras en minúscula — se normaliza a la forma canónica con guiones (`45008-12-298240 DV 32` → `45008-12-298240`); una que no se puede normalizar sin ambigüedad (`87772264`) es 400 `invalid_identifier` (#139)" }
                cedula: { type: string, description: "misma normalización que ruc (`8 777 2264` → `8-777-2264`)" }
                top: { type: integer, minimum: 1, maximum: 10, default: 10, description: "candidatos a devolver (#140; el lote usa 3 por defecto)" }
                country: { type: string }
      responses:
        "200":
          description: >
            candidatos rankeados (`top`, default 10, elegidos DESPUÉS de puntuar sobre una ventana de
            recall de 50 hits, para que BM25 nunca desplace a los homónimos exactos de un nombre común
            — #118), ordenados por `confidence` DESC (#140; el identificador y el tipo solo desempatan
            empates exactos), nunca uno por debajo de 0.5 (#139) + grupos cross-jurisdicción vacíos
            hasta pdlayer#796. `exact_homonyms` cuenta las identidades distintas cuyo nombre completo
            ES la consulta (sin distinguir mayúsculas, acentos ni puntuación); cuando es > 1 el nombre
            solo no desambigua — elegir por `disambiguator` (ruc/cédula). Cuando el identificador
            enviado casa con una empresa (RUC) Y una persona (cédula) ambas vienen con 0.99 y
            `matched_on: [ruc]` / `[cedula]`, y `note` lo dice (#141). Nivel gratuito / sin llave
            (#125): `disambiguator` y `summary.ruc_no_dv` vienen ENMASCARADOS (`8-***-2264`,
            `155***-2-2016`) — alcanza para distinguir homónimos, no para armar un directorio;
            `exact_homonyms`, `confidence`, `matched_on` y `type` quedan intactos. Las llaves con
            saldo reciben el identificador completo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
                  exact_homonyms: { type: integer, nullable: true, description: "identidades distintas con exactamente el nombre consultado, sobre toda la ventana de recall; null cuando no se mandó nombre" }
                  groups: { type: array, items: { type: object } }
                  note: { type: string }
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [public, free] }, description: "en toda respuesta no paga: `public` (sin llave, nivel 0) o `free` (llave con billetera en cero, nivel 1 — #138). Ausente con llaves con saldo" }
            X-Panadata-Public-Budget-Remaining: { schema: { type: integer }, description: "solo nivel 0: lo que queda del presupuesto compartido de hoy" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "solo nivel 1: entidades que quedan de las 30 de hoy (100 con dominio corporativo) (#138)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "solo nivel 1: entidades que quedan de las 500 del mes (1000 con dominio corporativo)" }
        "400": { description: "un body que no es un objeto JSON (`[…]`, un string, un número) — `invalid_body`; sin name/ruc/cedula (`invalid_body`), `top` fuera de 1..10 (`invalid_top`), o un identificador que no se puede normalizar — `invalid_identifier` {error: 'identificador no reconocido: <input>'} (#139); `invalid_search_query` (BackendError, retryable: false) — OpenSearch rechazó la forma de la consulta (#156). En los niveles no pagos el body se valida ANTES de leer cualquier contador, así que un 400 gana sobre un 429 (#138)" }
        "429":
          description: "solo nivel 0 — `public_budget_exhausted` {upgrade_url, reset_at (medianoche America/Panama)}; llave gratuita — `free_tier_limit` (ver FreeTierLimit: used, remaining, requested; solo cuando ESTA petición cruzaría el tope) con `Retry-After`"
          headers:
            Retry-After: { schema: { type: integer }, description: "llave gratuita: segundos hasta reset_at" }
          content: { application/json: { schema: { $ref: '#/components/schemas/FreeTierLimit' } } }
        "500": { $ref: '#/components/responses/SearchInternalError' }
        "502": { $ref: '#/components/responses/SearchBackendError' }
  /v5/resolve/batch:
    x-mcp-tool: resolve_entities_batch
    x-curl-example:
      - level: public
        curl: null
        note: "403 public_tier_locked: el lote necesita llave (gratuita alcanza)"
      - level: free
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave gratuita>' -H 'content-type: application/json' -d '{\"rows\":[{\"ref\":\"a\",\"name\":\"Luis Gomez\"},{\"ref\":\"b\",\"ruc\":\"155641197-2-2016\"}]}' https://api.panadata.net/v5/resolve/batch"
        note: "≤10 filas; con \"with_presence\":true hasta 100 (estimador, 5,000 filas/mes) — y entonces SOLO agregados: summary + presence, sin results[] (#137)"
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"rows\":[{\"name\":\"Luis Gomez\"}],\"top\":3,\"with_presence\":true}' https://api.panadata.net/v5/resolve/batch"
        note: "≤100 filas; DAT-RESOLVE × filas válidas + base una vez"
    post:
      summary: "#121 — resolver hasta 100 filas (name | names | ruc | cedula) en una llamada; un cargo por fila"
      description: >
        La primitiva de barrido: una petición por página de la lista de un cliente en lugar de
        una por fila. Cada fila se resuelve exactamente igual que POST /v5/resolve (misma ventana
        de recall, mismo ranking y mismo `exact_homonyms`), cortada a `top` candidatos (default 3).
        Una fila que trae `names` (≤5 grafías de UNA persona/empresa) es una consulta y UN cargo.
        Una fila inválida devuelve `{ref, input, outcome: invalid, error}` y nunca tumba el lote;
        no se cobra. `summary` agrupa las filas — `resolved_exact` (hit por identificador, o un
        único match por nombre ≥0.95), `ambiguous` (varios homónimos exactos, o un match ≥0.60
        que quien llama debe confirmar), `unresolved`, `invalid` — y reporta la distribución de
        homónimos exactos (`homonyms_p50/p90/max`) sobre las filas que traían nombre.
        `with_presence: true` agrega `summary.presence`: para las filas `resolved_exact`, cuántas
        identidades resueltas devolverían datos por código DAT (el mismo cálculo que
        `presencia.available_codes` de DAT-CORE, agregado — no sale ningún dato por entidad, sin
        cargo extra), más `summary.presence_unknown`: las filas `resolved_exact` cuya identidad no
        tiene mapa de presencia, que no suman a ningún código — «no lo sabemos», no «sin datos».
        Facturación: DAT-RESOLVE por fila válida + la tarifa base UNA vez por
        petición (a diferencia de /v5/entities/batch, que no tiene SKU y cobra base × N). Nivel
        gratuito: ≤10 filas, y el `disambiguator` / `summary.ruc_no_dv` de cada candidato
        enmascarado como en POST /v5/resolve (#125).

        La FORMA de la respuesta depende del nivel de acceso cuando `with_presence: true` (#137):
        llave con saldo → `results[]` (candidatos por fila) + `summary.presence`, sin cambios.
        Llave gratuita (billetera en cero) → el ESTIMADOR: `{country, count, estimator: true,
        summary{…, presence}, note}` y NADA por fila — sin `results[]`, sin gids, sin candidatos;
        hasta 100 filas por llamada; las filas van al presupuesto del estimador
        (FREE_TIER_ESTIMATOR_ROWS, 5000/mes → 402 al agotarse), NO al cupo de entidades de
        30/día · 500/mes, así que sigue funcionando cuando el cupo diario se agotó. Una cuenta
        gratuita puede dimensionar una lista; resolverla es el trabajo pago (o 10 filas por
        llamada, dentro del cupo, sin `with_presence`). Sin llave → 403.
        Los identificadores se normalizan antes de la búsqueda (#139): `8 777 2264`,
        `45008-12-298240 DV 32`, `e-8-30749` resuelven; uno que no se puede agrupar sin
        ambigüedad (`87772264`) es `outcome: invalid` con `error: "identificador no reconocido:
        <input>"` y no se cobra ni se cuenta. Los candidatos nunca traen `confidence` < 0.5
        (#139) y vienen ordenados por confidence DESC (#140). El `outcome` se decide sobre la
        lista rankeada completa ANTES del corte de `top`, así que `top: 1` no puede convertir un
        identificador ambiguo en uno exacto (#141).
      parameters:
        - { $ref: '#/components/parameters/DryRunQ' }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                country: { type: string }
                rows: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/ResolveRow' } }
                top: { type: integer, minimum: 1, maximum: 10, default: 3, description: "candidatos por fila" }
                with_presence: { type: boolean, default: false, description: "agrega la presencia por código DAT de las filas resolved_exact en summary.presence" }
      responses:
        "200":
          description: "con saldo, o gratuita sin with_presence: {country, count, results[], summary{rows, resolved_exact, ambiguous, unresolved, invalid, homonyms_p50, homonyms_p90, homonyms_max, presence?, presence_unknown?}, note}. Llave gratuita + with_presence (el estimador, #137): {country, count, estimator: true, summary{…, presence}, note} — sin results[]"
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [free] }, description: "respuestas a llave gratuita (nivel 1) (#138)" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "nivel 1: entidades que quedan de las 30 de hoy (100 con dominio corporativo; el estimador no las consume)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "nivel 1: entidades que quedan de las 500 / 1000 del mes" }
            X-Panadata-Free-Estimator-Rows-Remaining: { schema: { type: integer }, description: "nivel 1, solo con with_presence: filas que quedan de las 5000 del mes" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: "resultados por fila (llave con saldo; llave gratuita sin with_presence)"
                    required: [results]
                    properties:
                      country: { type: string }
                      count: { type: integer }
                      results: { type: array, items: { $ref: '#/components/schemas/ResolveBatchItem' } }
                      summary: { type: object }
                      note: { type: string }
                  - type: object
                    description: "el estimador gratuito (#137): solo agregados"
                    required: [estimator, summary]
                    properties:
                      country: { type: string }
                      count: { type: integer, description: "filas recibidas (= summary.rows)" }
                      estimator: { type: boolean, enum: [true] }
                      summary: { type: object, description: "rows, resolved_exact, ambiguous, unresolved, invalid, homonyms_p50/p90/max, presence{código DAT: filas resueltas con datos}" }
                      note: { type: string }
        "400": { description: "un body que no es un objeto JSON (`[…]`, un string, un número) — `invalid_body`; rows ausente/vacío, >100 filas (>10 en el nivel gratuito sin with_presence — el estimador gratuito acepta 100: `free_tier_batch_rows`), top fuera de 1..10 (`invalid_top`), country distinto de panama. Se valida ANTES de los contadores del nivel gratuito, así que un lote de 11 filas con 23/30 usados es este 400, no un 429 (#138). Una fila cuya consulta OpenSearch rechaza (400) NO es un 400 de la llamada: esa fila vuelve `outcome: invalid` (no se cobra) y el resto se resuelve (#156)" }
        "402": { description: "llave gratuita — `estimator_rows_exhausted`: filas con with_presence por encima de FREE_TIER_ESTIMATOR_ROWS (5000/mes); más es una conversación con Ventas. Llave con saldo — `insufficient_credits` (InsufficientCredits)" }
        "403": { description: "sin llave — `public_tier_locked`: el lote (estimador incluido) necesita una llave (gratuita alcanza)" }
        "429":
          description: "llave gratuita sin with_presence — `free_tier_limit` cuando used + filas cobrables > limit (ver FreeTierLimit: used, remaining, requested, `Retry-After`). Un lote que cabe se sirve; uno que cruzaría el tope se rechaza entero"
          headers:
            Retry-After: { schema: { type: integer }, description: "segundos hasta reset_at" }
          content: { application/json: { schema: { $ref: '#/components/schemas/FreeTierLimit' } } }
        "502":
          description: "`search_backend_error` para TODA la llamada (nada se cobra, nada cuenta): un rechazo por carga en una fila (429 / es_rejected_execution) se reintenta una vez tras una pausa de 0,3 s (como mucho 3 reintentos por llamada) y solo entonces aborta el lote; un timeout, un 5xx o una respuesta parcial aborta de inmediato, sin reintento — una página parcial de filas `unresolved` se leería como \"no existe\" (#155, #156). Reintentar tras `Retry-After`. Las demás fallas por fila (una consulta rechazada, una respuesta ilegible) degradan solo esa fila a `outcome: invalid`."
          headers:
            Retry-After: { schema: { type: integer }, description: "segundos a esperar antes de reintentar (5)" }
          content: { application/json: { schema: { $ref: '#/components/schemas/BackendError' } } }
  /v5/entities/{id}:
    x-mcp-tool: get_entity,quote_investigation
    x-mcp-tool-notes: "quote_investigation (pd-api-mcp #42) no tiene ruta propia: es ?include=DAT-FREE + un ?dry_run=1 por profundidad (DAT-CORE · perfil operativo · riesgo · DAT-ALL+DAT-SCREEN) sobre esta misma ruta, más GET /v5/catalog"
    x-curl-example:
      - level: public
        curl: "curl https://api.panadata.net/v5/entities/pa_own_4629361"
        note: "sin cabecera: ficha DAT-FREE reducida (existe/vigente/tipo/año/codigos_con_datos)"
      - level: free
        curl: "curl -H 'Authorization: Bearer pk_<llave gratuita>' 'https://api.panadata.net/v5/entities/pa_own_4629361?include=DAT-FREE'"
        note: "ficha completa (booleanos y rangos); sin include devuelve lo mismo"
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_4629361?include=DAT-CORE'"
        note: "registros: identidad + directores + presencia (0.65 + base)"
      - level: free
        curl: "curl -H 'Authorization: Bearer pk_<llave gratuita>' 'https://api.panadata.net/v5/entities/pa_own_4629361?include=DAT-CORE&dry_run=1'"
        note: "quote_investigation: cotiza una profundidad sin cobrar ni contar contra el tope; la tool repite el dry_run por profundidad"
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_4629361?include=DAT-ALL,DAT-SCREEN&dry_run=1'"
        note: "quote_investigation, profundidad «completo»: would_charge exacto, nada cobrado"
      - level: public
        curl: null
        note: "quote_investigation sin llave: el MCP responde siguiente_paso (pedir llave gratuita)"
    get:
      summary: Detalle de entidad (own) o de un activo, por id tipado
      # Nivel 0 (#126): también sin Authorization (cuando PUBLIC_TIER_ENABLED) → DatFree reducida.
      security: [{}, {bearerKey: []}]
      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.
        `?include=DAT-FREE` (#125, cualquier llave, 0 créditos + base) devuelve la ficha de
        señales (`DatFree`): booleanos, rangos y el menú de códigos con datos — nunca registros.
        Es lo que recibe una llave del nivel gratuito (y, con #126, una petición sin llave) cuando
        no manda `include`, y el ÚNICO código de datos de owner que puede pedir el nivel gratuito
        (DAT-CORE es pago: trae los directores por nombre). DAT-FREE se pide solo: combinado con
        otros códigos → 400.

        `include=DAT-NETWORK` (#135) rinde el grafo de interlock bajo `network` — el mismo grafo
        que devuelve `GET /v5/entities/{id}/network` con sus defaults (depth 2), sin el envoltorio
        de la ruta. En ids que no son `pa_own_*` es `null` y solo se cobra la tarifa base.

        **Las listas largas se resumen (#135).** Toda sección con valor lista (`importaciones`,
        `exportaciones`, `documentos_contraloria`, `avisos`, `licitaciones`, `planillas`, `noticias`,
        `noticias_globales`, `expedientes_judiciales`, `css_moroso_menciones`, `fincas`, `propiedades`,
        `bienes_muebles`, `naves`, `marcas`, `gacetas`, `fideicomisos`, `licencias`, `personal_licenses`,
        `company_licenses`, `idoneidades`, `documentos_escaneados`, `documentos_digitales`, `sanciones`,
        `sanciones_internacionales`, `coincidencias_pep`, `migrations`, `work_permits`, `organizaciones`,
        `entity_events`) se corta a `inline_limit` filas (env `DETAIL_INLINE_LIMIT`, default 10) y, solo
        cuando se corta, gana un hermano `<section>_meta` (`InlineMeta`) que apunta al sub-recurso
        paginado. Los resúmenes (`imports_summary`, `exports_summary`, `customs_summary`,
        `clasificacion`, `contacto`) y las listas de identidad de DAT-CORE (`directores`,
        `nombres_alternativos`) nunca se cortan. `documentos_contraloria` además produce
        `contraloria_summary` (totales por institución / año sobre las filas inline).
        La facturación no cambia: una lista cortada igual tiene filas, así que su código cobra normal.

        **Detalle compacto (#144, default `summary=compact`).** Después de #135 el mismo body de
        SUPER COSECHAS seguía pesando 630 KB por tres secciones que no son listas. Por defecto:
        `imports_summary` / `exports_summary` van **por año** (`TradeYearlySummary`: `years` +
        `{valor_cif, total_shipments, top_products×5}` por año — el desglose mes × fracción × país
        queda en GET /v5/entities/{id}/trade-profile); `customs_summary` pierde sus copias
        `imports` / `exports` (conserva `recent_*_summary` y las dos similitudes partida↔actividad);
        las filas de `fincas` / `propiedades` son **generales** (`RealEstateGeneral`: id tipado,
        ficha, ubicación, superficie, valor, `gravamenes_vigentes_count`, `acreedores_vigentes`,
        `ultima_compraventa`) y siempre llevan `fincas_meta` / `propiedades_meta` con `detail` →
        GET /v5/entities/{id}/real-estate (DAT-REALESTATE-INTEL), donde vive el historial completo
        de gravámenes / compraventas. `?summary=full` devuelve esas tres secciones exactamente como
        antes; `summary=full&inline_limit=100` es el body previo a #135. La facturación no cambia
        en ninguno de los dos modos.
      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). Cualquier código de /v5/catalog, más DAT-FREE (solo: la ficha de señales). Nivel gratuito: solo CHK-BIN (chequeo de existencia, identificador enmascarado) | DAT-FREE (default cuando se omite)" }
        - { name: inline_limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 }, description: "#135 — máximo de filas inline por sección-lista (default: env DETAIL_INLINE_LIMIT, 10). Con `summary=full`, 100 restituye el body previo a #135. Fuera de 1..100 → 400 `invalid_inline_limit`. No cambia el precio." }
        - { name: summary, in: query, required: false, schema: { type: string, enum: [compact, full], default: compact }, description: "#144 — `compact` (default): imports_summary/exports_summary por año (TradeYearlySummary), customs_summary sin sus copias imports/exports, fincas/propiedades como generales (RealEstateGeneral) + `<section>_meta.detail`. `full`: esas tres secciones tal como están guardadas (desglose mensual, historial completo de gravámenes). Cualquier otro valor → 400 `invalid_summary`. No cambia el precio." }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: "entidad o activo; con ?include=DAT-FREE (o una llave del nivel gratuito sin include) la ficha `DatFree`; sin llave, el subconjunto público de `DatFree`"
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [public, free] }, description: "en toda respuesta no paga: `public` (sin llave) o `free` (billetera en cero, #138); ausente con llaves con saldo" }
            X-Panadata-Public-Budget-Remaining: { schema: { type: integer }, description: "solo nivel 0" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "solo nivel 1: entidades que quedan de las 30 de hoy (100 con dominio corporativo)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "solo nivel 1: entidades que quedan de las 500 / 1000 del mes" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: "detalle / resumen de entidad / activo. Con códigos en include: las secciones de los tags resueltos (listas cortadas a inline_limit, cada lista cortada seguida de `<section>_meta`)"
                    properties:
                      network: { $ref: '#/components/schemas/NetworkGraph' }
                      contraloria_summary: { $ref: '#/components/schemas/ContraloriaSummary' }
                      imports_summary: { $ref: '#/components/schemas/TradeYearlySummary', description: "summary=compact (default); con summary=full el desglose guardado mes × fracción × país" }
                      exports_summary: { $ref: '#/components/schemas/TradeYearlySummary', description: "summary=compact (default); con summary=full el desglose guardado mes × fracción × país" }
                      customs_summary: { type: [object, "null"], description: "summary=compact: {recent_imports_summary, recent_exports_summary, import_tariff_activity_similarity, export_tariff_activity_similarity}; summary=full agrega `imports` / `exports` (copias de imports_summary / exports_summary)" }
                      fincas: { type: array, items: { $ref: '#/components/schemas/RealEstateGeneral' }, description: "summary=compact (default); con summary=full el ítem v4 con hipotecas / compraventas / record_events" }
                      propiedades: { type: array, items: { $ref: '#/components/schemas/RealEstateGeneral' }, description: "summary=compact (default); con summary=full el ítem v4" }
                      css_moroso_menciones: { type: array, items: { $ref: '#/components/schemas/CssMorosoMencion' }, description: "DAT-RISK — menciones en el listado de empleadores morosos de la CSS, la más reciente primero" }
                      fincas_meta: { $ref: '#/components/schemas/InlineMeta' }
                      propiedades_meta: { $ref: '#/components/schemas/InlineMeta' }
                    patternProperties:
                      "^[a-z_]+_meta$": { $ref: '#/components/schemas/InlineMeta' }
                  - { $ref: '#/components/schemas/DatFree' }
        "401": { description: "sin llave en una ruta no pública, o una llave pasada su `expires_at` — `key_expired` {error: api_key_expired, expired_at, upgrade_url} (#127)" }
        "429":
          description: "sin llave — `public_budget_exhausted`; llave gratuita — `free_tier_limit` (FreeTierLimit: period, limit, used, remaining, requested, reset_at, upgrade_url; 30/día y 500/mes; 100/día y 1000/mes con dominio corporativo) con `Retry-After`. Un id o un inline_limit inválido es 400 incluso en el tope (#138)"
          headers:
            Retry-After: { schema: { type: integer }, description: "llave gratuita: segundos hasta reset_at" }
          content: { application/json: { schema: { $ref: '#/components/schemas/FreeTierLimit' } } }
        "400": { description: "id inválido; `dat_free_exclusive` — DAT-FREE combinado con otros códigos; `invalid_inline_limit` — inline_limit fuera de 1..100; `invalid_summary` — summary fuera de {compact, full}" }
        "403": { description: "nivel gratuito pidiendo un código fuera de {CHK-BIN, DAT-RESOLVE, DAT-FREE}, o un id de activo (pa_finca_/pa_ph_/pa_edif_ → DAT-REALESTATE, pa_nave_/pa_bm_ → DAT-ASSET, pa_marca_ → DAT-IP, pa_imp_/pa_exp_ → DAT-TRADE: propietarios, titulares, contrapartes y montos son registros) — `free_tier_code_locked` con `locked_codes`, `free_tier_codes`, `upgrade_url`. Llave sandbox (modo de prueba) pidiendo un código sin datos sintéticos todavía — `sandbox_code_not_ready` {error, code, mode, codes, ready_codes}; o un `inline_limit` explícito distinto de 10 — `sandbox_param_not_ready` {error, code, mode, param}" }
        "404": { description: "no encontrado. Llave sandbox (modo de prueba): `not_in_sandbox` {error, code, mode} para cualquier id fuera del dataset sintético pa_own_2100000001–pa_own_2100000999, respondido antes de cualquier búsqueda" }
  # POST /v5/entities/{id}/update — re-scrape asíncrono, GRATIS (#117). Un OWNER (pa_own_*) crea
  # la UpdateRequest del owner lane (platform; misma fila que la ruta v4 deprecada, marcada v5) y
  # al ENTREGAR (confirm) cobra la lectura v5 de sus `?include=` — lo mismo que GET
  # /v5/entities/{id}?include=; sin include, nada. Un ACTIVO (finca / PH / edificio, epic #14)
  # encola una fila `api_live` por propiedad en jobs-api (carril prioritario `user`; un edificio
  # = una fila por unidad) que se auto-completa (scrape → ingest → pdf → OCR → dataland); los
  # datos refrescados se leen después por las rutas v5 cobradas. Gratis no es
  # ilimitado: tope anti-abuso de re-scrapes por key (429).
  /v5/entities/{id}/update:
    x-mcp-tool: request_entity_update,request_real_estate_update
    x-mcp-tool-notes: "owners (pa_own_*) → request_entity_update (lane de owner, pa_ur_*, consultable); activos (pa_finca_*/pa_ph_*/pa_edif_*) → request_real_estate_update (lane prioritaria user, pa_aur_*, consultable; un edificio encola una fila por unidad viva — `ids`, `units`)"
    x-curl-example:
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_4624742/update?include=DAT-CORE'"
        note: "owner: 202 gratis; al entregar se cobra la lectura DAT-CORE (0.66)"
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' https://api.panadata.net/v5/entities/pa_finca_153/update"
      - level: free
        curl: null
        note: "owner: 403 `update requests require a paid plan` (dry_run sí cotiza); con ?include= de pago, 403 free_tier_code_locked"
    parameters:
      - { $ref: '#/components/parameters/PathId' }
      - { $ref: '#/components/parameters/DryRunQ' }
      - { name: include, in: query, schema: { type: string }, description: "solo owners: códigos de producto separados por coma que traerá la ENTREGA y que se cobran a su precio de lectura v5 (base + códigos; las secciones vacías no se cobran). Omitir para un refresco pelado gratis (el resultado es el resumen v5). DAT-FREE no se acepta. Se ignora en activos." }
    post:
      summary: "Dispara un re-scrape asíncrono — owner (pa_own_*) o activo (finca / PH / edificio); el update es gratis"
      description: >
        El update en sí es gratis (sin tarifa base). OWNER (pa_own_*): crea un update request en
        la lane de owner y responde 202 `{id: pa_ur_N, lane: owner, poll: /v5/update-requests/pa_ur_N}`.
        Cuando el scrape termina, la entidad refrescada se entrega (por webhook, si está
        configurado, y en `result` de GET /v5/update-requests/{id}) con la MISMA forma que
        GET /v5/entities/{id}?include=<codes> (ids tipados, `country`, resumen compacto), y recién
        entonces quien llama paga el precio de lectura de los códigos `?include=` pedidos — p. ej.
        include=DAT-CORE cobra lo que cobra una lectura DAT-CORE (0.66); las secciones que vuelven
        vacías no se cobran; un update pelado (sin include) es gratis. Si el scrape falla, no se
        cobra nada. La billetera (y el tope mensual) debe cubrir la entrega al momento de la
        petición (402/429 antes de encolar nada); `?dry_run=1` la cotiza. Solo llaves con saldo.
        ACTIVO: finca / PH = esa propiedad (eje folio); edificio (pa_edif_*) = vuelve a traer
        cada unidad viva del edificio, una petición por unidad, que la flota scrapea en
        paralelo en la lane prioritaria (user) — se atiende antes que el backfill automático,
        sin límite de tasa, idempotente por unidad ya en curso. El 202 trae el id tipado de la
        primera petición (`pa_aur_<n>`) más `ids` (todas), `units`, `enqueued_units`,
        `failed_units` — y ningún entero suelto: `update_request_id` se retiró (#60), las lanes
        de owner y de activo tienen secuencias separadas, y un entero suelto consulta la lane de
        OWNER. Para el estado, consultar GET /v5/update-requests/{id} con `pa_aur_<n>`; los
        datos refrescados se leen por las rutas v5 cobradas. Tiempo típico con el ingest libre:
        una unidad en 1–3 min, un edificio de 50 unidades en 5–15 min.
        Ambas lanes: sujetas a la cuota de re-scrapes por key (429) — una llamada de activo pesa
        sus `enqueued_units` contra ella (un edificio de 143 unidades = 143 refrescos), y una
        llamada encola como mucho 500 unidades (413 `too_many_units`; actualizar las unidades
        una por una). Sin body.
      responses:
        "200": { description: "?dry_run=1 — {dry_run: true, would_charge, product_codes, credits}: owner = lo que cobraría la entrega (0 si es pelado); activo = 0" }
        "202": { description: "owner: {id: pa_ur_N, lane: owner, status, poll, entity_id: pa_own_N, update_request_id, requested_codes, update_charge: \"0\", delivery_charge, webhook_configured, note}. activo: {id: pa_aur_N, ids: [pa_aur_N, …], enqueued: true, asset_id, identifier: {folio}, units, enqueued_units, failed_units, lane: user, note} — una fila por propiedad (un edificio: cada unidad viva); los fallos parciales de jobs-api se informan en failed_units / enqueue_errors, y si ninguna salió → 502" }
        "400": { description: "id no panameño, un tipo de id distinto de own/finca/ph/edif, un código de include desconocido, o `dat_free_not_updatable`" }
        "402": { description: "owner: la billetera no cubre la entrega de los códigos de include pedidos (no se encola nada) — `insufficient_credits` (InsufficientCredits)" }
        "403": { description: "update de owner sin plan pago, o nivel gratuito pidiendo un código de include pago (`free_tier_code_locked`)" }
        "404": { description: "entidad / activo no encontrado (sin identificador de scrape)" }
        "413": { description: "activo: el edificio tiene más unidades vivas de las que una llamada puede encolar ({error: too_many_units, units, max_units}); actualizar las unidades una por una" }
        "429": { description: "cuota de re-scrapes agotada para esta key, o el tope de gasto mensual — `spending_limit_exceeded` (SpendingLimitExceeded)" }
        "502": { description: "activo: {error: scrape enqueue failed, detail}. owner: {error: scrape dispatch failed, id (pa_ur_*), status: failed, retryable: true} — falló el envío a SQS; la petición queda `failed` (no se cobra nada) para que las siguientes de la entidad no la esperen. Reintentar." }
  # GET /v5/update-requests/{id} — estado de un update request (PAN2-4987). Solo auth, sin cargo.
  # Lane OWNER (`pa_ur_*` de POST /v5/entities/pa_own_*/update, o el entero de la ruta v4
  # deprecada): misma semántica que GET /v4/update_requests/{id}, con ids tipados; `result` con
  # la forma v5 si la UR nació en v5 (#117), la v4 si nació en v4. Lane AUTOMATIC (`pa_aur_*`,
  # el id de un update de activo): estado + timestamps desde jobs-api (#60), sin eco del activo;
  # 501 sólo mientras jobs-api no tenga la ruta.
  /v5/update-requests/{id}:
    x-mcp-tool: get_update_status
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' https://api.panadata.net/v5/update-requests/pa_ur_310"
    parameters: [ { $ref: '#/components/parameters/PathId' } ]
    get:
      summary: "Consultar un update request por id tipado — lane de owner o de activo; gratis (solo auth)"
      description: >
        `pa_ur_<n>` (de POST /v5/entities/pa_own_*/update, o el entero suelto que devolvió el
        update de owner de v4, deprecado) lee la petición de la lane de owner: quien llama tiene
        que ser su dueño. `status` usa el mismo vocabulario que v4 (pending → completed | failed);
        una petición completada trae `result` rendido con los códigos pedidos — con la forma de
        GET /v5/entities/{id} cuando la petición nació en v5 (#117), con la forma v4 cuando nació
        en v4 — y una entrega de webhook fallida aparece en `error` sin cambiar `status`. Consultar
        nunca cobra (un update de owner en v5 se cobra una sola vez, al entregarse el resultado).
        `pa_aur_<n>` (lane de activo) responde el estado de la fila de jobs-api: `queued` |
        `running` (seguir consultando) → `completed` | `failed` | `unknown` (terminales; `unknown`
        = cerrada sin confirmación y sin dead-letter). Solo estado + timestamps: sin eco del activo
        y sin chequeo de pertenencia (la fila es del sistema y la comparten todos los que encolaron
        ese activo; el body no nombra nada). `result` es siempre null — los datos refrescados se
        leen por las rutas v5 normales (cobradas); el ingest/OCR puede aterrizar poco después de
        `completed`.
      responses:
        "200": { description: "{id: pa_ur_N, lane: owner, country, update_request_id, entity_id: pa_own_N, status, webhook_status, webhook_attempts, requested_codes, started_at, completed_at, result, error} · pa_aur_* → {id: pa_aur_N, lane: user | automatic (la cola de jobs-api que atiende la fila), country, status, started_at, completed_at, result: null, error}" }
        "400": { description: "no es un id de update request (se espera pa_ur_<n>, pa_aur_<n> o el entero)" }
        "404": { description: "no existe esa petición para quien llama (también cuando la jurisdicción del id tipado no coincide con la fila); pa_aur_* — jobs-api no tiene esa fila automática" }
        "501": { description: "pa_aur_* — solo mientras jobs-api no tenga su ruta de estado (brecha de despliegue): {id, lane: automatic, status: untracked, error}" }
        "502": { description: "pa_aur_* — jobs-api inalcanzable o con error: {error, code: jobs_api_error, retryable: true}" }
  /v5/entities/batch:
    x-mcp-tool: get_entities_batch
    x-curl-example:
      - level: free
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave gratuita>' -H 'content-type: application/json' -d '{\"ids\":[\"pa_own_42\",\"pa_finca_153\"]}' https://api.panadata.net/v5/entities/batch"
        note: "summaries con ruc_no_dv enmascarado; cada id cuenta contra 30/día"
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"ids\":[\"pa_own_42\",\"pa_finca_153\"]}' https://api.panadata.net/v5/entities/batch"
    post:
      parameters:
        - { $ref: '#/components/parameters/DryRunQ' }
      summary: Resolver hasta 100 ids tipados en una llamada
      description: >
        Cada id se resuelve contra la misma tabla que GET /v5/entities/{id}, con la forma de
        RESUMEN: un id de expediente (pa_entrada_*, pa_elemento_*, pa_relemento_*) devuelve su
        metadata y `has_ocr_text` pero nunca `ocr_text` — el texto es DAT-DOC y solo viaja en
        GET /v5/entities/{id}; los ids `own`, de migración y `gov` devuelven igualmente el
        resumen. 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" }
        "400": { description: "un body que no es un objeto JSON (`[…]`, un string, un número) — `invalid_body`; `ids` ausente, vacío o que no es un array; más de 100 ids" }
  /v5/entities/{id}/real-estate:
    x-mcp-tool: get_real_estate
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/real-estate?include_historical=1'"
      - level: free
        curl: null
        note: "403 free_tier_code_locked (DAT-REALESTATE-INTEL); la señal `inmuebles` está en DAT-FREE"
    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.
        #194 — `view=compact` (default) devuelve una fila por inmueble (generales +
        avalúo + conteos `eventos {hipotecas, compraventas}`, ~0.6 KB cada una);
        `view=full` devuelve el historial completo de gravámenes / compraventas por inmueble.
        `items` es una página (`limit` 1..200, default 100; `offset`) cortada a 200 KB de
        JSON; `items_meta {total, offset, limit, inline, truncated, next, view, size_capped?}`
        dice cuántos hay y dónde sigue la lista. `summary` cubre las primeras 200 tenencias
        sea cual sea la página; `summary.parcial` es true cuando el owner tiene más.
        Cada página es su propia compra (la misma regla de toda ruta paginada).
      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: view, in: query, schema: { type: string, enum: [compact, full], default: compact }, description: "#194 — compact = una fila por inmueble; full = con el historial de gravámenes / compraventas" }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses:
        "200": { description: "{entity, summary, items[], items_meta}" }
        "400": { description: "`invalid_page`: view / limit / offset fuera de rango (no se cobra)" }
  /v5/entities/{id}/dockets:
    x-mcp-tool: list_dockets
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/dockets?kind=entrada'"
    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}:
    x-mcp-tool: get_docket
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' https://api.panadata.net/v5/entities/pa_own_42/dockets/pa_entrada_88"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "importaciones"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/importaciones?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "exportaciones"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/exportaciones?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "trade-profile"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/trade-profile?limit=50'"
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get:
      summary: "Resumen aduanero derivado + flag de actividad inconsistente + panadata_score — DAT-TRADE"
      description: "`imports_summary` / `exports_summary` aquí son los resúmenes guardados COMPLETOS: año → {valor_cif, total_shipments, monthly{MM → {valor_cif, total_shipments, products{fracción → {valor_cif, shipments}}, countries{país → {valor_cif, shipments}}}}}. Desde #144 el detalle de entidad (GET /v5/entities/{id}) los trae solo por año (las 5 fracciones principales de cada año); el desglose mes × fracción × país vive aquí (y en el detalle con ?summary=full). `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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "licitaciones"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/licitaciones?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "contraloria"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/contraloria?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "avisos"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/avisos?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "marcas"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/marcas?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "gacetas"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/gacetas?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "noticias"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/noticias?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "css-morosos"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/css-morosos?limit=50'"
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get:
      summary: "Morosidad CSS (sensible) — DAT-RISK"
      description: "Las menciones del owner en los listados de empleadores morosos de la CSS, un ítem por fila de empleador listado, publicación más reciente primero (mismo orden que el `css_moroso_menciones` inline, así que `css_moroso_menciones_meta.next` lo continúa). Cada ítem trae el `pdf_url` del listado, nunca su texto OCR."
      responses:
        "200":
          description: "{entity, items[], pagination}"
          content:
            application/json:
              schema:
                type: object
                properties:
                  entity: { type: object }
                  items: { type: array, items: { $ref: '#/components/schemas/CssMorosoMencion' } }
                  pagination: { type: object, properties: { limit: { type: integer }, offset: { type: integer }, count: { type: integer }, has_more: { type: boolean } } }
  /v5/entities/{id}/legal:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "legal"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/legal?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "screening"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/screening?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "naves"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/naves?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "movable-assets"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/movable-assets?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "fideicomisos"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/fideicomisos?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "licenses"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/licenses?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "immigration"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/immigration?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "planillas"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/planillas?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "proponente-dossier"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/proponente-dossier?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "network"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/network?limit=50'"
    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:
    x-mcp-tool: get_entity_records
    x-mcp-record-type: "score"
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/entities/pa_own_42/score?limit=50'"
    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}:
    x-mcp-tool: search
    x-mcp-tool-by-surface:
      by-role: search_by_role
    x-curl-example:
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"q\":\"cerveza\",\"filters\":{\"hs\":\"2203\"},\"limit\":10}' https://api.panadata.net/v5/search/importaciones"
        note: "tier Pro+ en las superficies de inteligencia"
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"owner_id\":\"pa_own_42\",\"cargo\":\"Director\"}' https://api.panadata.net/v5/search/by-role"
        note: "MCP: search_by_role"
      - level: free
        curl: null
        note: "403 free_tier_code_locked: ninguna búsqueda en el nivel gratuito"
    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, lugar, corregimiento, colindante (lugar/corregimiento/colindante: frase sin tildes ni mayúsculas, ej. lugar='punta paitilla'); 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); planillas {institucion, cargo, estado, salary_min/_max, pep_min/_max (pep_score 0..1; ≥0.75 = coincidencia PEP, señal derivada del cargo, NO confirma que alguien sea PEP), fecha_inicio_min/_max (fecha de inicio en el cargo) — facetas: personas (personas distintas por cédula VÁLIDA — los placeholders como 8-88-88 y las cédulas truncadas no cuentan; cardinality: prácticamente exacta por debajo de ~40.000 personas, aproximada por encima, p. ej. sin filtros sobre toda la planilla; total_approx cuenta FILAS y una persona tiene varias), monthly_inicio (por mes de inicio), sin_fecha_inicio (filas sin fecha de inicio que cumplen todos los DEMÁS filtros: se cuenta ignorando el rango de fecha_inicio, que ya las excluye del resultado — es el denominador que una consulta por período deja fuera), sin_cedula (filas sin cédula válida: vacía, placeholder o truncada — el complemento exacto de las filas que cuenta `personas`)}; 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: "un body que no es un objeto JSON (`[…]`, un string, un número) — `invalid_body`; 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. También `invalid_search_query` (BackendError, retryable: false): OpenSearch rechazó la consulta por su forma — típicamente un valor de filtro del tipo equivocado (`{\"monto_min\": \"abc\"}`, un array donde va un término); corregirla, no reintentarla (#156)." }
        "500": { $ref: '#/components/responses/SearchInternalError' }
        "502": { $ref: '#/components/responses/SearchBackendError' }
  /v5/gov-entities:
    x-mcp-tool: search_gov_entities
    x-curl-example:
      - level: free
        curl: "curl -H 'Authorization: Bearer pk_<llave gratuita>' 'https://api.panadata.net/v5/gov-entities?q=MEDUCA'"
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' 'https://api.panadata.net/v5/gov-entities?q=MEDUCA'"
    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}:
    x-mcp-tool: get_gov_entity
    x-curl-example:
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' https://api.panadata.net/v5/gov-entities/pa_gov_7"
    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:
    x-mcp-tool: resolve_gov_entities
    x-curl-example:
      - level: paid
        curl: "curl -X POST -H 'Authorization: Bearer pk_<llave con saldo>' -H 'content-type: application/json' -d '{\"nombres\":[\"Ministerio de Educación\",\"CSS\"]}' https://api.panadata.net/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: "un body que no es un objeto JSON (`[…]`, un string, un número) — `invalid_body`; vacío / >100 / nombres que no son strings" }
  /v5/catalog:
    x-mcp-tool: list_product_codes
    x-curl-example:
      - level: free
        curl: "curl -H 'Authorization: Bearer pk_<llave gratuita>' https://api.panadata.net/v5/catalog"
      - level: paid
        curl: "curl -H 'Authorization: Bearer pk_<llave con saldo>' https://api.panadata.net/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>'.\"}" }
