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.

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

security:
  - bearerKey: []

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). 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)_[0-9]+$'
      examples: ["pa_own_42", "pa_finca_153", "pa_entrada_88", "pa_gov_7"]
    GovEntitySummary:
      type: object
      properties:
        id: { $ref: '#/components/schemas/PublicId' }
        nombre: { type: string }
        tipo: { $ref: '#/components/schemas/GovEntityTipo' }
        provincia: { type: string, nullable: true, description: "1..13 (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, description: "0..1" }
        match: { type: string, enum: [high, medium, low] }
        matched_on:
          type: array
          items: { type: string, description: "name | ruc | cedula" }
        summary: { type: object }
    DryRun:
      type: object
      properties:
        dry_run: { type: boolean }
        would_charge: { type: string }
        credits: { type: number }
        product_codes: { type: array, items: { type: string } }
  parameters:
    PathId: { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/PublicId' } }
    PageLimit: { name: limit, in: query, schema: { type: integer }, description: "page size (sub-resources ≤200; search ≤25)" }
    PageOffset: { name: offset, in: query, schema: { type: integer }, description: "page offset (search hard cap 200)" }
    DryRunQ: { name: dry_run, in: query, schema: { type: boolean } }

paths:
  /v5/entities:
    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 } }
  /v5/resolve:
    post:
      summary: "#4 — resolve name/ruc/cedula → ranked candidate gids (probabilistic)"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                ruc: { type: string }
                cedula: { type: string }
                country: { type: string }
      responses:
        "200":
          description: ranked candidates (+ empty cross-jurisdiction groups until pdlayer#796)
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidates: { type: array, items: { $ref: '#/components/schemas/ResolveCandidate' } }
                  groups: { type: array, items: { type: object } }
  /v5/entities/{id}:
    get:
      summary: Get entity (own) detail or an asset, by typed id
      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.
      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)" }
        - { name: dry_run, in: query, schema: { type: boolean } }
      responses:
        "200": { description: entity or asset }
        "400": { description: invalid id }
        "404": { description: not found }
  # POST /v5/entities/{id}/update — re-scrape de un ACTIVO (finca / PH / edificio), epic #14.
  # Encola una fila `automatic` en jobs-api y se auto-completa por el pipeline normal
  # (scrape → pdf → OCR → dataland); los datos refrescados se leen después por las rutas
  # v5 cobradas. Cobra solo la tarifa base; tope anti-abuso de re-scrapes por key (429).
  # Los OWNERS (pa_own_*) se actualizan por POST /v4/{jur}/entidades/{pk}/update.
  /v5/entities/{id}/update:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    post:
      summary: "Trigger an async re-scrape of an ASSET (finca / PH / edificio) — base fee only"
      description: >
        finca / PH = that one property (ficha axis). edificio (pa_edif_*) = re-pull every
        unit of the building via a listing search (name axis). Owners are NOT accepted here
        (400 → use POST /v4/{jurisdiction}/entidades/{pk}/update). Subject to the per-key
        re-scrape quota (429). No request body.
      responses:
        "202": { description: "{enqueued: true, asset_id, identifier, update_request_id, lane: automatic, note}" }
        "400": { description: "non-panama id, owner id, or an asset type other than finca/ph/edif" }
        "404": { description: "asset not found (no scrape identifier)" }
        "429": { description: "re-scrape quota exceeded for this key" }
        "502": { description: "{error: scrape enqueue failed, detail}" }
  /v5/entities/batch:
    post:
      summary: Resolve up to 100 typed ids in one call
      description: >
        Each id resolves exactly like GET /v5/entities/{id} — 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" } }
  /v5/entities/{id}/real-estate:
    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.
      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: dry_run, in: query, schema: { type: boolean } }
      responses: { "200": { description: "{entity, summary, items[]}" } }
  /v5/entities/{id}/dockets:
    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}:
    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:
    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:
    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:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get:
      summary: "Derived customs summary + activity-mismatch flag + panadata_score — DAT-TRADE"
      description: "`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:
    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:
    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:
    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:
    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:
    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:
    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:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "CSS delinquency (sensitive) — DAT-RISK", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/legal:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Judicial expedientes (sensitive) — DAT-RISK", responses: { "200": { description: "{entity, items[], pagination}" } } }
  /v5/entities/{id}/screening:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "AML: 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:
    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:
    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:
    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:
    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:
    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:
    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:
    parameters: [ { $ref: '#/components/parameters/PathId' }, { $ref: '#/components/parameters/PageLimit' }, { $ref: '#/components/parameters/PageOffset' }, { $ref: '#/components/parameters/DryRunQ' } ]
    get: { summary: "Dossier de proponente — DAT-PROPONENTE", description: "Inteligencia documental sobre la empresa COMO OFERENTE en compras públicas: los campos estructurados que se extrajeron de las propuestas que presentó a licitaciones — beneficiarios finales (accionistas), financieros y referencias bancarias, cumplimiento (paz y salvo / fianza / idoneidad) y capacidad (experiencia / personal / equipo). El vínculo empresa↔documento se resuelve por owner_id al relacionar, así que es una lectura indexada y no un barrido por RUC. `summary.by_group` cuenta el dossier COMPLETO (no la página) y `items` es la página, extracción más reciente primero; `items[].fields` es la salida cruda del extractor de ese tipo de documento, cuya forma depende del doctype.", responses: { "200": { description: "{entity, summary{by_group}, items[], pagination}" } } }
  /v5/entities/{id}/network:
    parameters:
      - { $ref: '#/components/parameters/PathId' }
      - { $ref: '#/components/parameters/PageLimit' }
      - { $ref: '#/components/parameters/PageOffset' }
      - { $ref: '#/components/parameters/DryRunQ' }
      - name: depth
        in: query
        required: false
        description: >-
          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:
    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}:
    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; 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); 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: "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." }
  /v5/gov-entities:
    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}:
    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:
    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: empty / >100 / non-string names }
  /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>'.\"}" }
