openapi: 3.1.0
info:
  title: Panadata API v5 (agent-native)
  version: "5.0.0-alpha"
  description: >
    Additive over the frozen v4. Entities AND assets are addressed by **typed opaque
    public ids** (Layer 1, issue #3) — the id carries the jurisdiction, so by-id routes
    take no `country`. v5 reuses the v4 product catalog + credit billing; every billable
    route supports `?dry_run=1` to preview cost. Panama is live; colombia/ecuador return 400.
    Re-read window (#218): a by-id read (`GET /v5/entities/{id}` and its sub-surfaces, except
    `/screening` and any read whose `include` carries DAT-SCREEN) that the same ACCOUNT already paid for in the last 24 h charges only the base
    fee — same path, same codes, same page; any key of the account. The data is read again
    (current, not cached) and the response carries `X-Panadata-Already-Purchased: <first
    purchase, ISO 8601>`. A dry_run of such a read quotes the base fee and adds `already_purchased`.
    Every billed route can refuse a key with balance (before running, at the full quote) with
    **402 `insufficient_credits`** (InsufficientCredits: balance, price, shortfall, upgrade_url,
    and `hint` → DAT-FREE when the balance still covers it) or **429 `spending_limit_exceeded`**
    (SpendingLimitExceeded: the monthly cap the account owner set — limit, consumed, price,
    resets_at, settings_url, `Retry-After`). Neither is billed. `error` keeps its old string in
    both (`insufficient credits`, `spending_limit_exceeded`); key on `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: >
        Typed opaque id: `{jur}_{type}_{pk}`. jur ∈ pa|co|ec. type ∈ own (entity),
        finca|ph|edif (real estate), nave|bm|marca|imp|exp (assets), entrada|elemento|relemento
        (dockets; elemento = an owner's, relemento = a finca/PH's), mig|wp|visaaut|imped
        (immigration processes — detail gated by DAT-LABOR), gov (government entity —
        Panama public institution, catalogue with its own identity), ur|aur (update
        requests — ur = owner re-scrape on the platform lane, aur = asset re-scrape on the
        automatic lane; two lanes, two databases, so the integer alone is ambiguous).
        Append-only type registry; decode is strict (bad id → 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 (first RUC segment) or null (national)" }
        match: { type: string, enum: [alias_exacto, nombre_norm, auto, ambiguo, similar] }
        score: { type: number, description: "1.0 for alias_exacto|nombre_norm|ambiguo (the text IS a known name); pg_trgm similarity (0.5..1) for similar|auto" }
        alias_usado: { type: string, description: "normalized alias that produced a `similar` hit" }
    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: "ALL tax ids of the institution (AMP has two). Canonical upper, no DV."
          items: { type: object, properties: { ruc: { type: string }, tipo_ruc: { type: string, enum: [nt, regular] }, vigente: { type: boolean } } }
        aliases:
          type: array
          description: "Every known spelling with provenance; `descartado` aliases are excluded."
          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: "present when the requested id was merged into this entity" }
    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 for alias_exacto|nombre_norm|ambiguo (the input IS a known name; for ambiguo the identity is not unique, see alternativas); for auto|candidato|sin_match the similarity of the best matching alias (null when none ≥ 0.5); null for vacio|descartado. Each entry of alternativas carries its own score" }
        alternativas: { type: array, items: { $ref: '#/components/schemas/GovEntitySummary' }, description: "homonyms first (match ambiguo, score 1.0), then similar entities with their own score; each entity at most once" }
    ResolveCandidate:
      type: object
      properties:
        gid: { $ref: '#/components/schemas/PublicId' }
        country: { type: string }
        confidence: { type: number, minimum: 0.5, maximum: 1, description: "0..1 — candidates come sorted by confidence DESC (#140); one below 0.5 is never emitted (#139)" }
        match: { type: string, enum: [high, medium, low] }
        matched_on:
          type: array
          items: { type: string, description: "name | ruc | cedula — the identifier the CANDIDATE carries: a persona matched through its cédula reports `cedula` even when the caller sent the digits as `ruc` (#141)" }
        disambiguator: { type: string, nullable: true, description: "ruc/cedula of the candidate; two same-name rows with DIFFERENT identifiers are two candidates, never merged" }
        duplicates: { type: array, items: { $ref: '#/components/schemas/PublicId' }, description: "same-identity rows folded into this candidate (present only when there were any)" }
        summary: { type: object }
    ResolveRow:
      type: object
      description: "one input of POST /v5/resolve/batch — at least one of name | names | ruc | cedula"
      properties:
        ref: { type: string, description: "caller's row key, echoed back verbatim (defaults to the row index)" }
        name: { type: string }
        names: { type: array, items: { type: string }, maxItems: 5, description: "spellings of the SAME name; one charge" }
        ruc: { type: string, description: "any spelling: `8 777 2264`, `45008-12-298240 DV 32`, `e-8-30749` are normalized to the canonical hyphenated form before the lookup (#139)" }
        cedula: { type: string, description: "same normalization as ruc" }
    ResolveBatchItem:
      type: object
      properties:
        ref: { description: "as sent, or the row index" }
        input: { type: object, description: "the row as sent, minus ref" }
        outcome: { type: string, enum: [resolved_exact, ambiguous, unresolved, invalid], description: "resolved_exact = the identifier hit ONE record, or a single ≥0.95 name match; ambiguous = several exact homonyms, a ≥0.60 match to confirm, or the identifier hit a company (RUC) AND a persona (cédula) — #141, decided over the full list before the `top` cut; unresolved = nothing ≥0.60; invalid = unusable row or an identifier that cannot be normalized (`identificador no reconocido: <input>`) — NOT billed, NOT counted (#139)" }
        exact_homonyms: { type: integer, nullable: true }
        candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
        note: { type: string, description: "only when the identifier matched more than one record (#141): how many and of which types — pick by summary.type / matched_on" }
        error: { type: string, description: "invalid rows only; the row is not billed" }
    FreeTierLimit:
      type: object
      description: "429 body of a free key whose request does not fit in the period allowance (#138). `used + requested > limit`: the message says «would exceed <window>'s remaining N»; `used >= limit`: «limit reached». Comes with `Retry-After` (seconds to reset_at) and the X-Panadata-Free-* headers."
      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: "entities this request would have counted (billable rows of the batch, 1 for a single call)" }
        reset_at: { type: string, format: date-time, description: "next midnight UTC (day) / first day of next month (month)" }
        upgrade_url: { type: string, description: "UPGRADE_URL — the purchase page (top-ups and plans, Sandbox onwards; default https://platform.panadata.net/facturacion, #142)" }
    InsufficientCredits:
      type: object
      description: "402 body for a key WITH balance that cannot pay this call — before the handler (the pre-check at the full quote) or, rarely, after it (the conditional debit lost a race with another request; the balance is then re-read). Not billed. Credits are strings. `balance` and `shortfall` are omitted when the post-handler re-read fails (never guessed); if that re-read finds the balance already covers `price` (a top-up landed in between), `shortfall` is omitted and the message says to retry. A key with NO balance normally gets the free-tier refusals instead (403 free_tier_code_locked / 429 free_tier_limit): the tier is decided when the key is authorized, so a wallet another request drains after that point gets this 402 with `balance: \"0.00\"`."
      required: [error, code, price, upgrade_url, message]
      properties:
        error: { type: string, enum: ["insufficient credits"], description: "unchanged legacy string — key on `code`" }
        code: { type: string, enum: [insufficient_credits] }
        message: { type: string, description: "what to do, in English: the price, the balance and the shortfall, the purchase link, and «DAT-FREE still works» when it does" }
        balance: { type: string, description: "wallet balance now, e.g. \"0.90\"" }
        price: { type: string, description: "credits this call costs (the full quote before the handler; what the debit tried after it)" }
        shortfall: { type: string, description: "price − balance" }
        hint: { type: string, enum: ["GET /v5/entities/{id}?include=DAT-FREE"], description: "only when the balance still covers a DAT-FREE read for this key: the signals card still answers" }
        upgrade_url: { type: string, description: "UPGRADE_URL — the purchase page (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: "429 body when this call would take the month's spending over the cap the account owner set (`spending_limits`). Not billed. Comes with `Retry-After` (seconds to resets_at). Credits are strings; `limit` and `consumed` are this period's (an expired period counts from 0) and are omitted if the row could not be re-read after the handler."
      required: [error, code, message, resets_at, settings_url]
      properties:
        error: { type: string, enum: [spending_limit_exceeded], description: "unchanged legacy string" }
        code: { type: string, enum: [spending_limit_exceeded] }
        message: { type: string, description: "opens with «Monthly spending limit exceeded»; then the price, what is used of the limit, the reset and the settings link" }
        price: { type: string }
        limit: { type: string }
        consumed: { type: string }
        resets_at: { type: string, format: date-time, description: "first day of next month UTC" }
        settings_url: { type: string, description: "SPENDING_LIMIT_URL — where the owner raises or removes the cap (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: "403 body for a sandbox-lane key (API test mode, `billing_lane: \"sandbox\"`) on a route that does not read the synthetic dataset yet. Sandbox keys are never billed and never read real data, so the request is refused before the route runs. A v5 `?dry_run=1` still answers the real price."
      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: "Error body of a search that OpenSearch did not answer completely or usefully (#155, #156). Three codes, three meanings: `search_backend_error` (502, retryable: true, with `Retry-After`) — OpenSearch was down, timed out, shed load (429 / es_rejected_execution), or answered PARTIALLY (failed shards, timed_out, or — on /v5/search — zero shards because every index behind the surface is missing); `invalid_search_query` (400, retryable: false) — OpenSearch rejected the query's shape, typically a filter value of the wrong type; `internal_error` (500, retryable: false) — the gateway could not read a good answer (a bug). None of them is billed or counted against the free allowance. Never a 200 with zero hits: that read as \"does not exist\" and was billed."
      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 only for search_backend_error: retry after `Retry-After` seconds. false: the same request will fail the same way" }
    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: >-
        Sibling of a list section of GET /v5/entities/{id} that was cut to the inline limit
        (`DETAIL_INLINE_LIMIT`, default 10; `?inline_limit=1..100`). Key name is `<section>_meta`
        (`importaciones_meta`, `documentos_contraloria_meta`, …). Absent when the list fits.
      required: [total, inline, truncated, next]
      properties:
        total: { type: [integer, "null"], description: "rows behind the list. For `fincas_meta`/`propiedades_meta` these are the CURRENT holdings the detail serves (#149): when the list was not cut it equals the rows shipped, and past ownership is reported apart in `historicas`. For every other section, presencia.counters; null when unknown" }
        inline: { type: integer, description: "rows inlined in this response (the limit)" }
        truncated: { type: boolean, description: "true when the list was cut. `fincas_meta` / `propiedades_meta` also appear uncut (false) in the default `summary=compact` mode (#144), to carry `detail`" }
        next: { type: [string, "null"], description: "paginated sub-resource with the rest, e.g. /v5/entities/{id}/importaciones?offset=10&limit=50; null when the section has no sub-resource (noticias_globales, entity_events) or when not truncated" }
        compact: { type: boolean, description: "#144 — only on `fincas_meta` / `propiedades_meta` in `summary=compact`: the rows are `RealEstateGeneral` (generals), not the v4 item" }
        historicas: { type: integer, description: "#149 — only on `fincas_meta` / `propiedades_meta`, when > 0 and the list was not cut: real estate the entity HELD and no longer holds (presencia counts the whole owner↔property relation, current or not). Explains a menu that advertises property the list does not carry" }
        historical_detail: { type: string, description: "#149 — `/v5/entities/{id}/real-estate?include_historical=1`: the portfolio including past ownership, each item carrying `activo`. Only alongside `historicas`" }
        detail: { type: string, description: "#144 — only on `fincas_meta` / `propiedades_meta`: `/v5/entities/{id}/real-estate` (DAT-REALESTATE-INTEL); its `?view=full` carries the full lien / sale history (debtors, creditors, amounts)" }
    # #144 — detalle compacto (default `summary=compact`): aduanas por año e inmuebles en generales.
    TradeYearEntry:
      type: object
      description: "One year of `imports_summary` / `exports_summary` in the compact detail (#144)."
      required: [valor_cif, total_shipments, top_products]
      properties:
        valor_cif: { type: number, description: "Σ CIF value of the year's shipments, 2 decimals" }
        total_shipments: { type: integer }
        top_products:
          type: array
          maxItems: 5
          description: "the 5 tariff lines (fracción arancelaria) with the highest CIF value in the year, summed over its 12 months; highest first"
          items:
            type: object
            required: [fraccion, shipments, valor_cif]
            properties:
              fraccion: { type: string, description: "tariff line as declared (e.g. 0702.00.00)" }
              shipments: { type: integer }
              valor_cif: { type: number }
    TradeYearlySummary:
      type: [object, "null"]
      description: >-
        `imports_summary` / `exports_summary` of GET /v5/entities/{id} in the default `summary=compact`
        mode (#144): the per-year totals plus the top 5 tariff lines of each year. `years` lists the
        years present (ascending); each year sits under its own key, as in the stored summary minus
        `monthly`. The full month × tariff line × country breakdown is in
        GET /v5/entities/{id}/trade-profile (DAT-TRADE-INTEL) and in the detail with `?summary=full`.
        null when the entity has no customs records on that side.
      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: >-
        One row of `fincas` / `propiedades` in the default `summary=compact` detail (#144): the
        generals of the property. The lien and sale history (hipotecas, compraventas, record_events,
        propietarios, entradas — debtors, creditors, amounts) is NOT here: it is DAT-REALESTATE-INTEL,
        GET /v5/entities/{id}/real-estate (`fincas_meta.detail`), or the detail with `?summary=full`.
      required: [id, ficha, codigo_ubicacion, ubicacion, superficie, valor, gravamenes_vigentes_count, acreedores_vigentes, ultima_compraventa]
      properties:
        id: { type: string, description: "typed id: pa_finca_<pk> | pa_ph_<pk> (the raw pk when the subtype is unknown)" }
        ficha: { type: [string, "null"], description: "Registro Público finca number" }
        codigo_ubicacion: { type: [string, integer, "null"] }
        ubicacion: { type: [string, "null"], description: "the registry `domicilio` text (corregimiento / distrito / provincia)" }
        edificio: { type: [string, "null"], description: "building name (propiedades only)" }
        superficie: { type: [string, number, "null"] }
        valor: { type: [number, string, "null"], description: "registered value (valor registral)" }
        gravamenes_vigentes_count: { type: [integer, "null"], description: "liens in force per pdlayer's lien ledger (PAN2-4946: constitutions minus MATCHED cancellations); null when the ledger is not computed for this property (unknown, not zero)" }
        acreedores_vigentes: { type: [array, "null"], items: { type: string }, description: "normalized creditor names of the liens in force; null when unknown" }
        ultima_compraventa:
          type: [object, "null"]
          description: "most recent sale deed on the folio; null when none"
          properties:
            fecha: { type: [string, "null"] }
            valor: { type: [number, string, "null"], description: "valor_traspaso read from the deed; null when OCR did not read it" }
    CssMorosoMencion:
      type: object
      description: >-
        One mention in the CSS (Caja de Seguro Social) monthly delinquent-employer listing: the
        listed employer's own row plus the publication it appeared in. Item of
        `css_moroso_menciones` (GET /v5/entities/{id}, DAT-RISK) and of
        GET /v5/entities/{id}/css-morosos. One item per employer row, so one owner can carry two
        items of the same month (`id` repeats, `empleador_id` does not). Newest publication first.
        Amounts are as declared in the public listing on its publication date: a reference, not a
        confirmed current debt. Employer fields are null when the listing's extraction did not read
        them (unknown, not zero): only the junio-2025, diciembre-2025 and junio-2026 listings carry
        amounts; older ones carry nombre/ruc only. The listing's OCR text (~5M chars, the whole
        country) is never returned: use `pdf_url`.
      properties:
        id: { type: [integer, "null"], description: "publication PK (panama_css_morosos)" }
        sid: { type: [string, "null"], description: "publication source id, e.g. listado-empleadores-morosos_junio-2026" }
        titulo_documento: { type: [string, "null"], description: "publication period, e.g. junio-2026" }
        fecha_publicacion: { type: [string, "null"] }
        pdf_url: { type: [string, "null"], description: "PDF of the full monthly listing" }
        empleador_id: { type: [integer, "null"], description: "employer-row PK (panama_css_morosos_entities); unique per mention" }
        nombre: { type: [string, "null"], description: "employer name as extracted from the listing (lower-cased)" }
        ruc: { type: [string, "null"], description: "RUC or cédula as printed in the listing" }
        tipo: { type: [string, "null"], description: "persona | organizacion | desconocido, per the extractor" }
        numero_patronal: { type: [string, "null"] }
        actividad_economica: { type: [string, "null"] }
        representante_legal: { type: [string, "null"], description: "legal representative as printed in the listing" }
        monto_adeudado: { type: [number, "null"], description: "total owed per the listing (B/.)" }
        cantidad_planillas: { type: [integer, "null"], description: "payrolls (planillas) in arrears per the listing" }
        condicion: { type: [string, "null"], description: "the listing's one-letter condition code (M, J, C, G, E), passed through as printed" }
    ContraloriaSummary:
      type: object
      description: "Totals of `documentos_contraloria` by institución and by year, computed over the rows the detail loaded (≤100) — `basis: inline_rows`, never the whole universe (`total`)."
      properties:
        basis: { type: string, const: inline_rows }
        rows: { type: integer }
        total: { type: [integer, "null"] }
        monto_total: { type: number }
        sin_monto: { type: integer, description: "rows whose monto could not be parsed" }
        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: >-
        The `network` section of GET /v5/entities/{id}?include=DAT-NETWORK (#135): the SAME graph
        GET /v5/entities/{id}/network returns at its defaults (depth 2, memberships 50/0, directors
        200/0, node budget 500), minus the route wrapper (`entity`, `pagination`,
        `directors_pagination`). `null` on ids that are not `pa_own_*` (assets, dockets,
        immigration, gov) and when the graph could not be built — DAT-NETWORK is then not charged.
      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: "200 body of any `?dry_run=1`. Paid keys: the credit quote. Free key (#142): `would_charge` \"0\" / `credits` 0 — that key never pays — plus `free_tier` with what the real call would do to the entity cap. A free key quoting a locked code keeps the credit price (what it would cost with credits) and `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: "paid key, re-read window (#218): this exact read was bought in the last 24 h, so `would_charge` / `credits` are the base fee"
          properties:
            purchased_at: { type: string, format: date-time }
            expires_at: { type: string, format: date-time }
    FreeTierQuote:
      type: object
      description: "free key only (#142). Reading the counters does not increment them: a quote never counts. `budget: daily` — the entity cap (30/day · 500/month; 100 · 1,000 with a corporate domain): top-level figures are today's, `month` this month's, and `would_exceed` is true when EITHER does not fit (exactly when the real call gets 429 free_tier_limit). `budget: estimator` — resolve/batch with with_presence: it does not touch the entity cap; `counts_as` are estimator rows (5,000/month) and running out is 402 estimator_rows_exhausted. `locked: true` — a code outside the free tier: the real call is 403 free_tier_code_locked and counts nothing. When the counters cannot be read, used/remaining/would_exceed are null."
      properties:
        budget: { type: string, nullable: true, enum: [daily, estimator, null] }
        counts_as: { type: integer, description: "entities (daily) or rows (estimator) the real call would count: billable rows of a batch (invalid rows count 0), ids of entities/batch, 1 otherwise" }
        period: { type: string, enum: [day, month], description: "period of used/limit/remaining: day for daily, month for estimator" }
        used: { type: integer, nullable: true }
        limit: { type: integer }
        remaining: { type: integer, nullable: true }
        month:
          type: object
          description: "daily budget only — this month's figures (UTC month)"
          properties:
            used: { type: integer, nullable: true }
            limit: { type: integer }
            remaining: { type: integer, nullable: true }
        would_exceed: { type: boolean, nullable: true, description: "the real call would be refused (429, or 402 for the estimator); null when the counters could not be read" }
        exceeded_period: { type: string, enum: [day, month], description: "only when would_exceed" }
        would_status: { type: integer, enum: [429, 402, 403], description: "only when the real call would be refused" }
        code: { type: string, enum: [free_tier_limit, estimator_rows_exhausted, free_tier_code_locked], description: "only when the real call would be refused — the `code` its body would carry" }
        reset_at: { type: string, format: date-time, description: "only when would_exceed — next midnight UTC (day) / first day of next month (month)" }
        locked: { type: boolean, description: "only for a quote of a code outside the free tier" }
        locked_codes: { type: array, items: { type: string }, description: "only with locked" }
        upgrade_url: { type: string, description: "UPGRADE_URL — the purchase page (default https://platform.panadata.net/facturacion)" }
  responses:
    InsufficientCredits:
      description: "`insufficient_credits` — the wallet cannot cover this call (see InsufficientCredits). Not billed."
      content: { application/json: { schema: { $ref: '#/components/schemas/InsufficientCredits' } } }
    SpendingLimitExceeded:
      description: "`spending_limit_exceeded` — the account's monthly spending cap (see SpendingLimitExceeded). Not billed."
      headers:
        Retry-After: { schema: { type: integer }, description: "seconds until resets_at" }
      content: { application/json: { schema: { $ref: '#/components/schemas/SpendingLimitExceeded' } } }
    SandboxRouteNotReady:
      description: "`sandbox_route_not_ready` — the key is a test key (sandbox lane) and this route does not serve synthetic data yet. Not billed. Use `?dry_run=1` to see the real price, or a live key for real data."
      headers:
        X-Panadata-Mode: { schema: { type: string, enum: [sandbox] }, description: "on every response a sandbox-lane key gets from a billed route" }
      content: { application/json: { schema: { $ref: '#/components/schemas/SandboxRefusal' } } }
    SearchBackendError:
      description: "`search_backend_error` — OpenSearch did not answer completely: down, timed out, load-shedding (429 / es_rejected_execution), or a partial answer (failed shards / timed_out). Never a 200 with zero hits (that read as \"does not exist\" and was billed); not billed, not counted against the free allowance — retry after `Retry-After`."
      headers:
        Retry-After: { schema: { type: integer }, description: "seconds to wait before retrying (5)" }
      content: { application/json: { schema: { $ref: '#/components/schemas/BackendError' } } }
    SearchInternalError:
      description: "`internal_error` {error, code, retryable: false} — the gateway could not read OpenSearch's answer (a bug on our side). Not billed; retrying the same request will not help."
      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: "page size (sub-resources ≤200; search ≤25)" }
    PageOffset: { name: offset, in: query, schema: { type: integer }, description: "page offset (search hard cap 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: "preview the charge — free at every access level (a free/no-key caller can quote paid codes); charges nothing, consumes no counters. Body: DryRun; a free key gets `would_charge` \"0\" plus a `free_tier` block (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: Search entities (name/ruc); ranked summaries with typed ids
      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: results }
        "400": { description: "neither q nor ruc; `invalid_search_query` (BackendError, retryable: false) — OpenSearch rejected the query's shape (#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 — resolve name/ruc/cedula → ranked candidate gids (probabilistic)"
      # 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: "up to 5 spellings of the SAME name ('Luis A. Gomez', 'Gomez Perez, Luis'); one query, one charge. Use instead of `name`." }
                ruc: { type: string, description: "any spelling — spaces, a trailing ` DV nn`, lowercase letters — is normalized to the canonical hyphenated form (`45008-12-298240 DV 32` → `45008-12-298240`); one that cannot be normalized unambiguously (`87772264`) is 400 `invalid_identifier` (#139)" }
                cedula: { type: string, description: "same normalization as ruc (`8 777 2264` → `8-777-2264`)" }
                top: { type: integer, minimum: 1, maximum: 10, default: 10, description: "candidates to return (#140; the batch defaults to 3)" }
                country: { type: string }
      responses:
        "200":
          description: >
            ranked candidates (`top`, default 10, chosen AFTER scoring over a 50-hit recall window,
            so exact homonyms of a common name are never crowded out by BM25 — #118), sorted by
            `confidence` DESC (#140; identifier and type only break exact ties), never one below
            0.5 (#139) + empty cross-jurisdiction groups until pdlayer#796. `exact_homonyms`
            counts the distinct identities whose full name IS the query (case/accent/punctuation
            folded); when it is > 1 the name alone does not disambiguate — pick by
            `disambiguator` (ruc/cedula). When the identifier sent matches a company (RUC) AND a
            persona (cédula) both come at 0.99 with `matched_on: [ruc]` / `[cedula]` and `note`
            says so (#141). Free tier / no key (#125): `disambiguator` and `summary.ruc_no_dv`
            come MASKED (`8-***-2264`, `155***-2-2016`) — enough to tell homonyms apart, not to
            build a directory; `exact_homonyms`, `confidence`, `matched_on`, `type` are untouched.
            Paid keys get the full identifier.
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
                  exact_homonyms: { type: integer, nullable: true, description: "distinct identities with exactly the queried name, over the whole recall window; null when no name was sent" }
                  groups: { type: array, items: { type: object } }
                  note: { type: string }
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [public, free] }, description: "on every unpaid response: `public` (no key, level 0) or `free` (key with an empty wallet, level 1 — #138). Absent on paid keys" }
            X-Panadata-Public-Budget-Remaining: { schema: { type: integer }, description: "level 0 only: what is left of today's shared budget" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "level 1 only: entities left of today's 30 (100 with a corporate domain) (#138)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "level 1 only: entities left of the month's 500 (1000 with a corporate domain)" }
        "400": { description: "a body that is not a JSON object (`[…]`, a string, a number) — `invalid_body`; no name/ruc/cedula (`invalid_body`), `top` outside 1..10 (`invalid_top`), or an identifier that cannot be normalized — `invalid_identifier` {error: 'identificador no reconocido: <input>'} (#139); `invalid_search_query` (BackendError, retryable: false) — OpenSearch rejected the query's shape (#156). On the unpaid levels the body is validated BEFORE any counter is read, so a 400 wins over a 429 (#138)" }
        "429":
          description: "level 0 only — `public_budget_exhausted` {upgrade_url, reset_at (midnight America/Panama)}; free key — `free_tier_limit` (see FreeTierLimit: used, remaining, requested; only when THIS request would cross the cap) with `Retry-After`"
          headers:
            Retry-After: { schema: { type: integer }, description: "free key: seconds until 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 — resolve up to 100 rows (name | names | ruc | cedula) in one call; one charge per row"
      description: >
        The sweep primitive: one request per page of a client list instead of one per row.
        Each row is resolved exactly like POST /v5/resolve (same recall window, ranking and
        `exact_homonyms`), cut to `top` candidates (default 3). A row that carries `names`
        (≤5 spellings of ONE person/company) is one query and ONE charge. An invalid row
        returns `{ref, input, outcome: invalid, error}` and never fails the lot; it is not
        billed. `summary` buckets the rows — `resolved_exact` (identifier hit, or a single
        ≥0.95 name match), `ambiguous` (several exact homonyms, or a ≥0.60 match the caller
        must confirm), `unresolved`, `invalid` — and reports the exact-homonym distribution
        (`homonyms_p50/p90/max`) over the rows that carried a name. `with_presence: true`
        adds `summary.presence`: for the `resolved_exact` rows, how many resolved identities
        would return data per DAT code (the same computation as DAT-CORE's
        `presencia.available_codes`, aggregated — no per-entity data leaves, no extra charge),
        plus `summary.presence_unknown`: the `resolved_exact` rows whose identity has no
        presence map, which add to no code — unknown, not "no data".
        Billing: DAT-RESOLVE per valid row + the base fee ONCE per request (unlike
        /v5/entities/batch, which has no SKU and bills base × N). Free tier: ≤10 rows, and
        every candidate's `disambiguator` / `summary.ruc_no_dv` masked as in POST /v5/resolve (#125).

        The response SHAPE depends on the access level when `with_presence: true` (#137):
        paid key → `results[]` (per-row candidates) + `summary.presence`, unchanged. Free key
        (empty wallet) → the ESTIMATOR: `{country, count, estimator: true, summary{…, presence},
        note}` and NOTHING per row — no `results[]`, no gids, no candidates; up to 100 rows per
        call; the rows go to the estimator budget (FREE_TIER_ESTIMATOR_ROWS, 5000/month → 402
        when spent), NOT to the 30/day · 500/month entity allowance, so it keeps working when
        the daily allowance is spent. A free account can size a list; resolving it is the paid
        job (or 10 rows per call, within the allowance, without `with_presence`). No key → 403.
        Identifiers are normalized before the lookup (#139): `8 777 2264`, `45008-12-298240 DV 32`,
        `e-8-30749` resolve; one that cannot be grouped unambiguously (`87772264`) is
        `outcome: invalid` with `error: "identificador no reconocido: <input>"` and is neither
        billed nor counted. Candidates never carry `confidence` < 0.5 (#139) and come sorted by
        confidence DESC (#140). The `outcome` is decided over the whole ranked list BEFORE the
        `top` cut, so `top: 1` cannot turn an ambiguous identifier into an exact one (#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: "candidates per row" }
                with_presence: { type: boolean, default: false, description: "aggregate DAT-code presence of the resolved_exact rows into summary.presence" }
      responses:
        "200":
          description: "paid, or free without with_presence: {country, count, results[], summary{rows, resolved_exact, ambiguous, unresolved, invalid, homonyms_p50, homonyms_p90, homonyms_max, presence?, presence_unknown?}, note}. Free key + with_presence (the estimator, #137): {country, count, estimator: true, summary{…, presence}, note} — no results[]"
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [free] }, description: "free key (level 1) responses (#138)" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "level 1: entities left of today's 30 (100 with a corporate domain; the estimator does not consume them)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "level 1: entities left of the month's 500 / 1000" }
            X-Panadata-Free-Estimator-Rows-Remaining: { schema: { type: integer }, description: "level 1, with_presence only: rows left of the month's 5000" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: "per-row results (paid key; free key without 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: "the free estimator (#137): aggregates only"
                    required: [estimator, summary]
                    properties:
                      country: { type: string }
                      count: { type: integer, description: "rows received (= summary.rows)" }
                      estimator: { type: boolean, enum: [true] }
                      summary: { type: object, description: "rows, resolved_exact, ambiguous, unresolved, invalid, homonyms_p50/p90/max, presence{DAT code: resolved rows with data}" }
                      note: { type: string }
        "400": { description: "a body that is not a JSON object (`[…]`, a string, a number) — `invalid_body`; rows missing/empty, >100 rows (>10 on the free tier without with_presence — the free estimator takes 100: `free_tier_batch_rows`), top outside 1..10 (`invalid_top`), non-panama country. Validated BEFORE the free-tier counters, so an 11-row batch at 23/30 is this 400, not a 429 (#138). A row whose query OpenSearch rejects (400) is NOT a 400 for the call: that row comes back `outcome: invalid` (not billed) and the rest resolve (#156)" }
        "402": { description: "free key — `estimator_rows_exhausted`: with_presence rows past FREE_TIER_ESTIMATOR_ROWS (5000/month); more is a Sales conversation. Key with balance — `insufficient_credits` (InsufficientCredits)" }
        "403": { description: "no key — `public_tier_locked`: the batch (estimator included) needs a (free) key" }
        "429":
          description: "free key without with_presence — `free_tier_limit` when used + billable rows > limit (see FreeTierLimit: used, remaining, requested, `Retry-After`). A batch that fits is served; one that would cross the cap is refused whole"
          headers:
            Retry-After: { schema: { type: integer }, description: "seconds until reset_at" }
          content: { application/json: { schema: { $ref: '#/components/schemas/FreeTierLimit' } } }
        "502":
          description: "`search_backend_error` for the WHOLE call (nothing billed, nothing counted): a load rejection on a row (429 / es_rejected_execution) is retried once after a 0.3 s pause (at most 3 retries per call) and only then aborts the batch; a timeout, 5xx or partial answer aborts at once, no retry — a partial page of `unresolved` rows would read as \"does not exist\" (#155, #156). Retry after `Retry-After`. Other per-row failures (a rejected query, an unreadable answer) degrade only that row to `outcome: invalid`."
          headers:
            Retry-After: { schema: { type: integer }, description: "seconds to wait before retrying (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: Get entity (own) detail or an asset, by typed id
      # Nivel 0 (#126): también sin Authorization (cuando PUBLIC_TIER_ENABLED) → DatFree reducida.
      security: [{}, {bearerKey: []}]
      description: >
        Asset ids (pa_finca_*, pa_ph_*) return the same enriched real-estate item the
        portfolio endpoint ships (plus the full ANATI geometry): precio_por_m2 with
        propio→edificio precedence, precio_por_m2_origen, and a valor_estimado computed
        from that same effective rate — see /v5/entities/{id}/real-estate for the full
        field semantics.
        `?include=DAT-FREE` (#125, any key, 0 credits + base) returns the signals card
        (`DatFree`): booleans, ranges and the menu of codes with data — never records.
        It is what a free-tier key (and, with #126, a request without a key) gets when it sends
        no `include`, and the ONLY owner data code the free tier may ask for (DAT-CORE is paid:
        it carries directors by name). DAT-FREE is asked for alone: combined with other codes → 400.

        `include=DAT-NETWORK` (#135) renders the interlock graph under `network` — the same graph
        `GET /v5/entities/{id}/network` returns at its defaults (depth 2), without the route wrapper.
        On ids that are not `pa_own_*` it is `null` and only the base fee is charged.

        **Long lists are summarized (#135).** Every list-valued section (`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`) is cut to `inline_limit` rows (env `DETAIL_INLINE_LIMIT`, default 10) and, only
        when cut, gains a sibling `<section>_meta` (`InlineMeta`) pointing at the paginated sub-resource.
        Summaries (`imports_summary`, `exports_summary`, `customs_summary`, `clasificacion`, `contacto`)
        and DAT-CORE identity lists (`directores`, `nombres_alternativos`) are never cut. `documentos_contraloria`
        additionally yields `contraloria_summary` (totals by institución / year over the inline rows).
        Billing is unchanged: a cut list still has rows, so its code bills normally.

        **Compact detail (#144, default `summary=compact`).** After #135 the same SUPER COSECHAS body
        was still 630 KB because of three non-list sections. By default: `imports_summary` /
        `exports_summary` are **per year** (`TradeYearlySummary`: `years` + `{valor_cif,
        total_shipments, top_products×5}` per year — the month × tariff line × country breakdown
        stays in GET /v5/entities/{id}/trade-profile); `customs_summary` drops its `imports` /
        `exports` copies (keeps `recent_*_summary` and the two tariff-activity similarities);
        `fincas` / `propiedades` rows are **generals** (`RealEstateGeneral`: typed id, ficha,
        ubicación, superficie, valor, `gravamenes_vigentes_count`, `acreedores_vigentes`,
        `ultima_compraventa`) and always carry `fincas_meta` / `propiedades_meta` with `detail` →
        GET /v5/entities/{id}/real-estate (DAT-REALESTATE-INTEL), where the full lien / sale history
        lives. `?summary=full` returns those three sections exactly as before; `summary=full&inline_limit=100`
        is the pre-#135 body. Billing is unchanged in both modes.
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: include, in: query, schema: { type: string }, description: "comma product codes (own only). Any code of /v5/catalog, plus DAT-FREE (stand-alone: the signals card). Free tier: only CHK-BIN (existence check, masked identifier) | DAT-FREE (default when omitted)" }
        - { name: inline_limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 }, description: "#135 — max rows inlined per list section (default: env DETAIL_INLINE_LIMIT, 10). With `summary=full`, 100 restores the pre-#135 body. Outside 1..100 → 400 `invalid_inline_limit`. Does not change the price." }
        - { name: summary, in: query, required: false, schema: { type: string, enum: [compact, full], default: compact }, description: "#144 — `compact` (default): imports_summary/exports_summary per year (TradeYearlySummary), customs_summary without its imports/exports copies, fincas/propiedades as generals (RealEstateGeneral) + `<section>_meta.detail`. `full`: those three sections as stored (monthly breakdown, full lien history). Anything else → 400 `invalid_summary`. Does not change the price." }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: "entity or asset; with ?include=DAT-FREE (or a free-tier key without include) the `DatFree` card; without a key, the public subset of `DatFree`"
          headers:
            X-Panadata-Tier: { schema: { type: string, enum: [public, free] }, description: "on every unpaid response: `public` (no key) or `free` (empty wallet, #138); absent on paid keys" }
            X-Panadata-Public-Budget-Remaining: { schema: { type: integer }, description: "level 0 only" }
            X-Panadata-Free-Remaining-Today: { schema: { type: integer }, description: "level 1 only: entities left of today's 30 (100 with a corporate domain)" }
            X-Panadata-Free-Remaining-Month: { schema: { type: integer }, description: "level 1 only: entities left of the month's 500 / 1000" }
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: "entity detail / summary / asset. With include codes: the sections of the resolved tags (lists cut to inline_limit, each cut list followed by `<section>_meta`)"
                    properties:
                      network: { $ref: '#/components/schemas/NetworkGraph' }
                      contraloria_summary: { $ref: '#/components/schemas/ContraloriaSummary' }
                      imports_summary: { $ref: '#/components/schemas/TradeYearlySummary', description: "summary=compact (default); with summary=full the stored month × tariff line × country breakdown" }
                      exports_summary: { $ref: '#/components/schemas/TradeYearlySummary', description: "summary=compact (default); with summary=full the stored month × tariff line × country breakdown" }
                      customs_summary: { type: [object, "null"], description: "summary=compact: {recent_imports_summary, recent_exports_summary, import_tariff_activity_similarity, export_tariff_activity_similarity}; summary=full adds `imports` / `exports` (copies of imports_summary / exports_summary)" }
                      fincas: { type: array, items: { $ref: '#/components/schemas/RealEstateGeneral' }, description: "summary=compact (default); with summary=full the v4 item with hipotecas / compraventas / record_events" }
                      propiedades: { type: array, items: { $ref: '#/components/schemas/RealEstateGeneral' }, description: "summary=compact (default); with summary=full the v4 item" }
                      css_moroso_menciones: { type: array, items: { $ref: '#/components/schemas/CssMorosoMencion' }, description: "DAT-RISK — CSS delinquent-employer mentions, newest first" }
                      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: "no key on a non-public route, or a key past `expires_at` — `key_expired` {error: api_key_expired, expired_at, upgrade_url} (#127)" }
        "429":
          description: "no key — `public_budget_exhausted`; free key — `free_tier_limit` (FreeTierLimit: period, limit, used, remaining, requested, reset_at, upgrade_url; 30/day and 500/month; 100/day and 1000/month with a corporate domain) with `Retry-After`. An invalid id or inline_limit is a 400 even at the cap (#138)"
          headers:
            Retry-After: { schema: { type: integer }, description: "free key: seconds until reset_at" }
          content: { application/json: { schema: { $ref: '#/components/schemas/FreeTierLimit' } } }
        "400": { description: "invalid id; `dat_free_exclusive` — DAT-FREE combined with other codes; `invalid_inline_limit` — inline_limit outside 1..100; `invalid_summary` — summary not in {compact, full}" }
        "403": { description: "free tier asking for a code outside {CHK-BIN, DAT-RESOLVE, DAT-FREE}, or for an asset id (pa_finca_/pa_ph_/pa_edif_ → DAT-REALESTATE, pa_nave_/pa_bm_ → DAT-ASSET, pa_marca_ → DAT-IP, pa_imp_/pa_exp_ → DAT-TRADE: owners, holders, counterparties and amounts are records) — `free_tier_code_locked` with `locked_codes`, `free_tier_codes`, `upgrade_url`. Sandbox key (test mode) asking for a code with no synthetic data yet — `sandbox_code_not_ready` {error, code, mode, codes, ready_codes}; or an explicit `inline_limit` other than 10 — `sandbox_param_not_ready` {error, code, mode, param}" }
        "404": { description: "not found. Sandbox key (test mode): `not_in_sandbox` {error, code, mode} for any id outside the synthetic dataset pa_own_2100000001–pa_own_2100000999, answered before any lookup" }
  # 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 (owner lane, pa_ur_*, pollable); assets (pa_finca_*/pa_ph_*/pa_edif_*) → request_real_estate_update (priority user lane, pa_aur_*, pollable; a building enqueues one row per live unit — `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: "owners only: comma product codes the DELIVERY will carry and charge at their v5 read price (base + codes, empty sections free). Omit for a free bare refresh (the result is the v5 summary). DAT-FREE is not accepted. Ignored for assets." }
    post:
      summary: "Trigger an async re-scrape — owner (pa_own_*) or asset (finca / PH / edificio); the update is free"
      description: >
        The update itself is free (no base fee). OWNER (pa_own_*): creates an owner-lane update
        request and answers 202 `{id: pa_ur_N, lane: owner, poll: /v5/update-requests/pa_ur_N}`.
        When the scrape completes, the refreshed entity is delivered (webhook, if configured,
        and `result` on GET /v5/update-requests/{id}) in the SAME shape as
        GET /v5/entities/{id}?include=<codes> (typed ids, `country`, compact summary), and only
        then the caller pays the read price of the requested `?include=` codes — e.g.
        include=DAT-CORE bills what a DAT-CORE read bills (0.66); sections that come back empty
        are not charged; a bare update (no include) is free. If the scrape fails, nothing is
        charged. The wallet (and monthly cap) must cover the delivery at request time (402/429
        before anything is enqueued); `?dry_run=1` quotes it. Paid keys only.
        ASSET: finca / PH = that one property (folio axis); edificio (pa_edif_*) = re-pull every
        live unit of the building, one request per unit, scraped in parallel by the fleet on
        the priority (user) lane — served before the automatic backfill, no rate limit,
        idempotent per unit already in flight. The 202 carries the typed id of the first
        request (`pa_aur_<n>`) plus `ids` (all of them), `units`, `enqueued_units`,
        `failed_units` — and no bare integer: `update_request_id` was dropped (#60), the owner
        and asset lanes have separate sequences, and a bare integer polls the OWNER lane. Poll
        GET /v5/update-requests/{id} with `pa_aur_<n>` for status; read the refreshed data via
        the billed v5 routes. Typical wall-clock with an idle ingest: a unit in 1–3 min, a
        50-unit building in 5–15 min.
        Both lanes: subject to the per-key re-scrape quota (429) — an asset call weighs its
        `enqueued_units` against it (a 143-unit building = 143 refreshes), and one call may
        enqueue at most 500 units (413 `too_many_units`; update the units one by one). No
        request body.
      responses:
        "200": { description: "?dry_run=1 — {dry_run: true, would_charge, product_codes, credits}: owner = what the delivery would charge (0 when bare); asset = 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}. asset: {id: pa_aur_N, ids: [pa_aur_N, …], enqueued: true, asset_id, identifier: {folio}, units, enqueued_units, failed_units, lane: user, note} — one row per property (a building: every live unit); partial jobs-api failures are reported in failed_units / enqueue_errors, none succeeded → 502" }
        "400": { description: "non-panama id, an id type other than own/finca/ph/edif, an unknown include code, or `dat_free_not_updatable`" }
        "402": { description: "owner: the wallet cannot cover the delivery of the requested include codes (nothing enqueued) — `insufficient_credits` (InsufficientCredits)" }
        "403": { description: "owner update without a paid plan, or free tier asking for a paid include code (`free_tier_code_locked`)" }
        "404": { description: "entity / asset not found (no scrape identifier)" }
        "413": { description: "asset: the building has more live units than one call may enqueue ({error: too_many_units, units, max_units}); update the units one by one" }
        "429": { description: "re-scrape quota exceeded for this key, or the monthly spending limit — `spending_limit_exceeded` (SpendingLimitExceeded)" }
        "502": { description: "asset: {error: scrape enqueue failed, detail}. own: {error: scrape dispatch failed, id (pa_ur_*), status: failed, retryable: true} — the SQS send failed; the request is marked `failed` (nothing charged) so later requests for the entity do not wait on it. Retry." }
  # 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: "Poll an update request by typed id — owner or asset lane; free (auth only)"
      description: >
        `pa_ur_<n>` (from POST /v5/entities/pa_own_*/update, or the bare integer the deprecated
        v4 owner update returned) reads the owner-lane request: the caller must own it. `status`
        uses the same vocabulary as v4 (pending → completed | failed); a completed request
        carries `result` rendered with the requested codes — in the GET /v5/entities/{id} shape
        when the request was created on v5 (#117), in the v4 shape when it was created on v4 —
        and a failed webhook delivery surfaces in `error` without changing `status`. Polling
        never charges (a v5 owner update is billed once, when its result is delivered).
        `pa_aur_<n>` (asset lane) answers the jobs-api row's status: `queued` |
        `running` (keep polling) → `completed` | `failed` | `unknown` (terminal; `unknown` =
        closed with no confirm and no dead-letter). Status + timestamps only: no asset echo
        and no ownership check (the row is system-owned and shared by every caller that
        enqueued the asset; the body names nothing). `result` is always null — read the
        refreshed data via the normal (billed) v5 routes; ingest/OCR can land shortly after
        `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 (the jobs-api queue serving the row), country, status, started_at, completed_at, result: null, error}" }
        "400": { description: "not an update request id (want pa_ur_<n>, pa_aur_<n> or the integer)" }
        "404": { description: "no such request for this caller (also when the typed jurisdiction does not match the row); pa_aur_* — jobs-api has no such automatic row" }
        "501": { description: "pa_aur_* — only while jobs-api predates its status route (deploy gap): {id, lane: automatic, status: untracked, error}" }
        "502": { description: "pa_aur_* — jobs-api unreachable or erroring: {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: Resolve up to 100 typed ids in one call
      description: >
        Each id resolves against the same table as GET /v5/entities/{id}, at the SUMMARY
        shape: a docket id (pa_entrada_*, pa_elemento_*, pa_relemento_*) returns its
        metadata and `has_ocr_text` but never `ocr_text` — the text is DAT-DOC and travels
        only in GET /v5/entities/{id}; `own`, immigration and `gov` ids likewise return
        the summary. Asset ids (pa_finca_*, pa_ph_*) carry the enriched real-estate item
        semantics (precio_por_m2 propio→edificio precedence, precio_por_m2_origen,
        consistent valor_estimado) documented on /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: "per-id records with found flag; a failing id degrades to {found:false, error} and never fails the rest of the batch" }
        "400": { description: "a body that is not a JSON object (`[…]`, a string, a number) — `invalid_body`; `ids` missing, empty or not an array; more than 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: Real-estate portfolio — precio_por_m2 + superficie-weighted average
      description: >
        precio_por_m2 has propio→edificio precedence: the unit's own sale-derived rate
        (average_ph_price) wins; the building average only fills gaps. Each item carries
        precio_por_m2_origen ("propio" | "edificio" | null) and valor_estimado is always
        computed from the SAME effective rate. Each item also carries
        valor_estimado_confianza (int 1-100): market-evidence confidence for the
        estimate, computed from the building's per-year sale counters over CALENDAR-year
        windows (10y volume + 3y recency; own recent sale floors it at 70). It is null
        whenever valor_estimado is null (and while a building's counters are not yet
        backfilled). Note this field is 1-100 by design, unlike resolve's 0..1
        confidence. NOTE the detail/search asymmetry: the real-estate search filter
        `price_m2` matches only the unit's OWN rate in the index — units whose detail
        shows an inherited rate do NOT match that filter.
        #194 — `view=compact` (default) returns one row per property (generales +
        valuation + `eventos {hipotecas, compraventas}` counts, ~0.6 KB each);
        `view=full` returns the full lien / sale history per property. `items` is a page
        (`limit` 1..200, default 100; `offset`) cut to 200 KB of JSON; `items_meta
        {total, offset, limit, inline, truncated, next, view, size_capped?}` says how
        many there are and where the list continues. `summary` covers the first 200
        holdings whatever the page; `summary.parcial` is true when the owner has more.
        Each page is its own purchase (same rule as every paginated route).
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
        - { name: include_historical, in: query, schema: { type: boolean }, description: "also return divested holdings (activo=false), tagged" }
        - { name: view, in: query, schema: { type: string, enum: [compact, full], default: compact }, description: "#194 — compact = one row per property; full = with the lien / sale history" }
        - { 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 out of range (not billed)" }
  /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: List registry dockets (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: One docket + OCR text (docket_id encodes 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: docket with ocr_text } }
  # Owner-scoped data sub-resources (epic #14). Paginated (?limit ≤200 / ?offset),
  # facet/record shapes vary; each bills the noted product code + base; all honor ?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: "Customs imports — 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: "Customs exports — 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: "Derived customs summary + activity-mismatch flag + panadata_score — DAT-TRADE"
      description: "`imports_summary` / `exports_summary` here are the FULL stored summaries: year → {valor_cif, total_shipments, monthly{MM → {valor_cif, total_shipments, products{fracción → {valor_cif, shipments}}, countries{país → {valor_cif, shipments}}}}}. Since #144 the entity detail (GET /v5/entities/{id}) carries them per year only (top 5 tariff lines per year); the month × tariff line × country breakdown lives here (and in the detail with ?summary=full). `import_tariff_activity_similarity` / `export_tariff_activity_similarity` (null when the entity has no customs records on that side, no operating notice in force, or none of its tariff codes is in the embedding catalog) = {sum, len, total, value_share_85, total_normalized, intermediary, revision, computed_at, <year>: {sum, len, total, value_share_85, total_normalized}}. `len` = shipments scored. `total` = shipment-weighted mean of the max cosine (multilingual-e5) between each tariff code's official description and the entity's declared activities; its floor for unrelated text is ~0.83, so it is NOT comparable across entities and NOT the basis of the label. `value_share_85` = share (0-1) of shipments whose tariff code matches a declared activity at cosine > 0.85: the actual tariff<->activity coherence signal. `total_normalized` in baja | media | alta is derived from `value_share_85` with fixed cutoffs (baja < 0.25 <= media < 0.60 <= alta), never from `total`. `intermediary` = true when at least 50% of the declared ISIC activities are wholesale / retail / transport / logistics / postal (divisions 46, 47, 49-53): such entities import broadly by nature, so a low share is expected there, not an anomaly. `revision` identifies the algorithm and scale (2 = code-level e5 + share-based label; an object WITHOUT `revision` was computed by the retired per-record algorithm on a different scale): only compare objects that carry the same revision. `computed_at` = ISO-8601 UTC. Per year, `total_normalized` and `value_share_85` are null when that year's `len` is 0 (no shipments)."
      responses: { "200": { description: trade profile } }
  /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: "Public tenders — 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: "Comptroller contracts — 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: "Operation notices (avisos) — 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: "Trademarks — 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: "Official gazette (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: "Adverse-media (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: "CSS delinquency (sensitive) — DAT-RISK"
      description: "The owner's mentions in the CSS delinquent-employer listings, one item per listed employer row, newest publication first (same order as the inline `css_moroso_menciones`, so `css_moroso_menciones_meta.next` continues it). Each item carries the listing's `pdf_url`, never its OCR text."
      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: "Judicial expedientes (sensitive) — 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: local + international sanctions + PEP coincidences + pre-computed reputational risk — DAT-SCREEN", description: "Adds `risk` = {kinds, notas}. `risk.kinds.reputacional` is the pre-computed reputational score (a new kind lands under `kinds` without a breaking change); `risk.notas` ALWAYS ships (empty object on the happy path) with `{motivo}` per kind (machine-readable code; no free-text detail travels), motivo in sin_score | sin_cobertura | sin_breakdown | drift_version | screening_fallido | error (drift_version = the RULES/WEIGHTS versions of this image differ from the ones the persisted score was computed with, so nothing is recomputed; error also covers the fresh mode failing on its own, in which case `components` / `score_recomputado` / `score_sin_screening` still ship). Fields when present: `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`. Top-level (outside `risk`, so it is there even when the entity has no score): `sanctions_international_status` = ok | fallido — the state of THIS call's live sanctions.io pass. `fallido` means the pass could not run, so `sanctions_international` being empty says NOTHING about the entity; today most entities have no score, and the `sin_score` nota would otherwise hide the vendor failure. Degraded states, all normal: (1) kind null + motivo sin_score — the entity was never scored, which is the NORMAL answer today, not an error; (2) score + cobertura WITHOUT components/modes (motivo sin_breakdown, drift_version or error); (3) kind null + motivo error — the block failed and the rest of the screening is still valid; (4) `score_fresco`/`delta`/`screening_fresco_at` null with motivo screening_fallido when the live sanctions.io pass could not run — not screened is NOT clean. `score` ALWAYS travels with `cobertura` (causas evaluadas/total + faltantes): a score without coverage is never emitted, and scores are NOT comparable between owners with different coverage. Modes: the persisted `score`; `score_sin_screening` (same aggregation excluding the international-sanctions cause — in v1 it equals `score` for the whole corpus, because that cause stays sin_evaluar until the screening verdicts of PAN2-5216/5217 exist); and `score_fresco` + `delta` + `cobertura_fresca` + `components_frescos` + `screening_fresco_at`, re-aggregated over THIS response's live pass (cached up to 1h) — `components_frescos` is that re-aggregation's cited breakdown (same claves/order as `components`, with the live pass as the sanctions component's `cita`) and `cobertura_fresca` its own coverage, which in v1 covers one cause MORE than the persisted one. `score_recomputado` is the persisted breakdown re-aggregated under THIS image's config, and `delta` = `score_fresco` - `score_recomputado`: the PURE EFFECT OF THE SCREENING, comparable because both terms come from the same breakdown under the same config and differ only in the substituted component. Staleness of the persisted score is a SEPARATE reading: `score_recomputado` != `score` (legitimate — the score is computed in batches, `score` is what the client sees, and it is NOT recomputed here). A `delta` of 0 with a `score_recomputado` above `score` therefore means: the live screening changed nothing, the stored score is just old. Only the 8 international-sanctions lists (SDN, NONSDN, UN, CFSP, UK-SANCTIONS, SSI, CMIC, OFAC-OTHERS) count as a hit for that cause: jurisdiction lists (FATF, HRJ-*, HIGH-RISK-JURISDICTIONS) qualify the country, and PEP/CRIME lists belong to other causes of the score. Sanctions/PEP hits are NAME-MATCHES, never confirmed identity: verify manually before acting on them. No band is exposed — the consumer defines the cutoffs.", 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: "Vessels (documentos[] refs; OCR pending 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: "Movable assets (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: "Trusts (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: "Commercial + personal licenses/idoneidades — DAT-BIZ", responses: { "200": { description: license sets } } }
  /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: "Work permits + migrations + visas + impedimentos (sensitive) — 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: immigration } } }
  /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: "Government payroll (sensitive: cédula+salary+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: >-
          How many hops to walk. 2 (default) is the readable answer: who this
          entity is connected to, and through whom. 3 adds the third hop and is
          much bigger — on a real subject, 303 nodes against 72. 1 returns the
          pre-graph response only. Depth does NOT change the price.
        schema: { type: integer, minimum: 1, maximum: 3, default: 2 }
      - name: directors_offset
        in: query
        required: false
        description: Offset into `directors` (page size is fixed at 200; see `directors_pagination`).
        schema: { type: integer, minimum: 0, default: 0 }
      - name: include_family_inference
        in: query
        required: false
        description: >-
          EXPERIMENTAL, opt-in, and removable. Adds a layer over the graph that flags nodes sharing
          a surname with the subject. Off by default: without this parameter the response is
          byte-for-byte what it is today, and the feature can be withdrawn without disturbing the
          graph. Do not build a stable integration on it.

          This is NOT confirmed kinship, and no source of kinship exists in the system — it is a
          surname match, the same class of evidence as a sanctions name-match. Each candidate
          carries HOW RARE each shared surname is (`uno_en`), which is the number that makes the
          disclaimer honest: a subject surnamed GONZALEZ draws a false positive on ~17.7% of
          five-member boards, against ~0.2% for a rare surname. The response says so itself in
          `family_inference.aviso` when the subject's surname is common.

          Two levels, ordered by evidence and never cut by a threshold: `probable_familiar`
          (two shared surnames) and `posible_vinculo` (one). The weak level is NOT noise to be
          filtered — it is the level that catches parents and children, who normally share only one
          surname.

          Marked nodes carry `posible_familiar`; nodes without it were simply not flagged, which is
          NOT the same as "evaluated and discarded" — for coverage read `family_inference`. The
          `posibles_familiares[]` block groups a person's several registry spellings into ONE
          candidate with `alias`, because the same person is routinely inscribed several ways.
        schema: { type: boolean, default: false }
    get:
      summary: "Director/shareholder interlock — DAT-NETWORK. memberships+directors plus the flat graph; 2 hops by default, 3 with ?depth=3, same price"
      description: >-
        The whole graph is one edge type: `(entity) --cargo--> (organization)`,
        walked in both directions. A natural person is only ever the SOURCE of an
        edge (people have no directors); an organization can be both, so
        cross-directorship shows up as two edges between the same two nodes.

        The deep response is FLAT (unique `nodes` + `edges`), not sectioned: the
        same node is reachable by several paths, so sectioning would duplicate it.
        The sectioned view is derivable — edges whose `to` is the root are "my
        directors", edges whose `from` is the root are "boards I sit on". Edges
        carry no direction field because they are always member→organization.

        Additive: every key of the historical response keeps its name and
        meaning; the graph keys are extra.

        The walk goes 2 levels unless `?depth=3` asks for the third. There is no
        per-level billing: every depth costs the same flat SKU, so `depth` is a
        size knob, not a pricing tier.

        Coverage is declared, never silent. Every node carries `expanded` and
        `memberships_count` (both always present, whatever the node), and every
        unexpanded one carries `pruned` with the reason:

          * `high_degree` — policy. Hub firms and nominee directors with tens of
            thousands of memberships are reported *with* `memberships_count` and
            never expanded, at any budget. Sets `truncated`.
          * `budget_exhausted` — incidental; a narrower request can reach it.
            Sets `truncated`.
          * `unresolved` — `owner_id IS NULL` in the registry: a leaf by
            construction, shown but not traversable. Does NOT set `truncated`:
            it is a limit of the data, not of the walk.
          * `max_depth` — the requested depth ended here. Does NOT set
            `truncated`: it is the reach that was asked for, not a failure to
            cover something.

        `truncation_reasons` may additionally contain `level_rows_capped`, which
        is not a node-level reason: it means one traversal level hit its row cap,
        so some members of that level were never considered at all.

        Node ids of the form `_unresolved_N` are LOCAL TO THIS RESPONSE — they
        depend on traversal order and budget, so the same registry row can be
        `_unresolved_2` at depth=2 and `_unresolved_5` at depth=3. Never persist
        them as identity.

        `limit`/`offset` page the root's `memberships` collection and
        `directors_offset` pages `directors`; neither bounds the graph, which is
        bounded by its own node budget.

        WHAT A NAME IS. The registry's name field holds four different things,
        and every node and director says which one it got in `nombre_kind`:

          * `persona`  — a natural person.
          * `entidad`  — a company or law firm. Corporate directors (companies
            whose business IS sitting on boards) land here, and they are a
            compliance signal in their own right, not noise.
          * `lista`    — several people in one field, most often under
            `Apoderado`. NOT split: there is no way to separate them without
            occasionally splitting wrong, and a bad split signed by this API is
            worse than the raw text. Shown whole so the caller can decide.
          * `clausula` — a RULE rather than a name ("EL PRESIDENTE DE LA
            SOCIEDAD SERÁ EL REPRESENTANTE LEGAL…"). Roughly 78% of the rows
            that never resolved to an entity are these.

        When the registry glued the cargo onto the name (`CARLA LOPEZ
        (TESORERA)`), `nombre` keeps the raw string and `nombre_limpio` carries
        the name alone. `is_noise` is DEPRECATED: it is now just
        `nombre_kind == "clausula"`. Prefer `nombre_kind` — the boolean cannot
        tell a clause from a company from a list.

        DERIVED EDGES. An edge with `derivada: true` does not come from a
        registry row: it is something we resolved. Today there is one, cargo
        `representante_legal`, and it exists because the registry inscribes the
        legal representative as a CLAUSE rather than a name — so the graph knew
        who the president was but not who represents the company, which is the
        part that matters for KYC.

        It is only emitted when the resolved name matches a member who is
        currently `activo`. If it does not match an active member, no edge is
        emitted and `representante_legal_no_verificable` counts it: the
        derivation is a cache and can lag a board change, and on a KYC surface
        abstaining is correct where guessing is not. A non-zero count means
        "for that many organizations we would not vouch for who represents
        them", not that they have no representative.
      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 + per-surface counters — DAT-SCORE", responses: { "200": { description: score } } }
  # Cross-entity faceted search (epic #14 phase 3). Facets-first; rows capped (≤25),
  # hard max-offset 200 (anti-bulk). Flat per-search DAT-*-SEARCH (+ 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: "Faceted search. 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: "full-text query" }
                filters: { type: object, description: "surface-specific, e.g. {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`)}; ranges via <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: "a body that is not a JSON object (`[…]`, a string, a number) — `invalid_body`; unknown filter key — {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: Search Panama government entities by any spelling (tilde, casing, abbreviation, acronym)
      description: >
        Exact normalized alias first (`match: alias_exacto|nombre_norm`, score 1.0), then pg_trgm
        similarity over every known alias (`match: similar`, score ≥ 0.5). Homonyms (two `OJO DE AGUA`
        in the same province) come back as separate rows with `match: ambiguo` — never merged.
        `q` must have at least 3 characters (400 otherwise); if fewer than 3 alphanumeric characters
        remain after normalization (accents/punctuation stripped) the answer is `items: []`.
        `tipo`/`provincia` are applied BEST-EFFORT on the top-N similarity hits, not pushed into
        SQL: a filtered list may come back shorter than `limit` even when more matches exist.
        Read-only: never creates aliases. Billing: base.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 3 }, description: "institution name, any variant (≥3 chars)" }
        - { name: tipo, in: query, schema: { $ref: '#/components/schemas/GovEntityTipo' }, description: "best-effort filter within the similarity top-N" }
        - { name: provincia, in: query, schema: { type: string }, description: "1..13 — best-effort filter within the similarity top-N" }
        - { name: limit, in: query, schema: { type: integer, maximum: 50, default: 20 } }
        - { $ref: '#/components/parameters/DryRunQ' }
      responses:
        '200':
          description: ranked rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  query: { type: object }
                  items: { type: array, items: { $ref: '#/components/schemas/GovEntitySummary' } }
        '400': { description: missing q, or q shorter than 3 characters }
  /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: Government entity detail — tipo, provincia, ALL its RUC-NT and every alias with provenance
      description: >
        `{id}` must be a `pa_gov_*` id (400 otherwise). If the entity was merged into another
        (`redirect_id`), the response is the surviving entity with `redirected_from` set and a
        `Location` header — a 200, not a 3xx (v5 convention). The same id also resolves through
        `GET /v5/entities/{id}` and `/v5/entities/batch`. Billing: base.
      parameters:
        - { $ref: '#/components/parameters/PathId' }
        - { $ref: '#/components/parameters/DryRunQ' }
      responses:
        '200': { description: detail, content: { application/json: { schema: { $ref: '#/components/schemas/GovEntityDetail' } } } }
        '400': { description: not a pa_gov id }
        '404': { description: unknown id, or retired without replacement }
  /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: Resolve a client list of institution names (≤100) to government entities — read-only
      description: >
        One result per input, same order. `alias_exacto|nombre_norm` = identity; `auto` = similarity
        above the ingest threshold (would be accepted, alias NOT created here); `ambiguo` = homonyms,
        see `alternativas`; `candidato|sin_match` = no identity, nothing invented; `descartado` = known
        non-institution (e.g. the procurement portal's test entity). Repeated names are resolved once
        (one row per input is still returned, in order). Billing: base × N names.
      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: resolutions
          content:
            application/json:
              schema:
                type: object
                properties:
                  results: { type: array, items: { $ref: '#/components/schemas/GovEntityResolution' } }
                  note: { type: string }
        '400': { description: "a body that is not a JSON object (`[…]`, a string, a number) — `invalid_body`; empty / >100 / non-string names" }
  /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: Product codes + prices + id grammar (free)
      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>'.\"}" }
