Saltar a contenido

Esquema de la base de datos

El corpus de Tarno es SQL plano (sin ORM): las migraciones bajo ../db/migrations/ son la única fuente de verdad. Esta página es una referencia legible por humanos generada a partir de esas migraciones. El corpus es neutral — cero campos ni referencias cruzadas de RMC (impuesto por pnpm neutrality).

  • Motor: PostgreSQL 16 + pgvector (halfvec + HNSW), pg_trgm, fuzzystrmatch, unaccent, búsqueda de texto completo en español.
  • Herramienta de migración: dbmate (cada archivo tiene -- migrate:up / -- migrate:down).

Diagrama entidad–relación

erDiagram
    instancia ||--o{ instancia_clase : "classes"
    instancia ||--o{ instancia_persona : "owners/agents"
    instancia ||--o{ anotacion : "events"
    anotacion ||--o{ anotacion_persona : "who appears"
    persona ||--o{ anotacion_persona : "link (nullable)"
    instancia |o--o| instancia : "renewal chain"
    persona ||--o{ instancia_persona : "role"
    clase ||--o{ instancia_clase : ""
    estado ||--o{ instancia : "status"
    tipo_signo ||--o{ instancia : "sign type"
    pais ||--o{ persona : "country"
    raw_source ||--o{ dead_letter : "re-derive handle"
    consumer ||--o{ api_key : "owns"

Corpus (entidades neutrales)

instancia — la solicitud de marca

estado y tipo_signo están tipados con CHECK, no con clave foránea (migr. …126, Fase 37 ESQ-01), y instancia_clase.clase_numero con CHECK (BETWEEN 1 AND 45). El vocabulario va literal en la restricción, no derivado de la tabla: así sigue siendo cierto cuando ESQ-16 retire los catálogos. ✅ Las etiquetas amigables ES/EN/PT viven en packages/core/src/i18n/, nunca en la base (owner, 2026-09-10), con negociación por Accept-Language. Los 14 estado y los 14 tipo_signo están completos en los tres idiomas.

El español NO es una traducción: es la grafía LITERAL de INAPI, leída de estado.nombre y tipo_signo.nombre en producción. Se conserva tal cual —incluido su Caducado en masculino, que desentona con los otros trece— porque «arreglarlo» sería dejar de poder cruzar la etiqueta con lo que INAPI publica. EN y PT son traducciones nuestras, no texto oficial de ninguna oficina.

Ampliar ESTADO_VOCABULARY sin etiquetar el código nuevo es un error de COMPILACIÓN, no un hueco que se descubre sirviendo. Verificado añadiendo un estado de prueba: TS2741.

Los 45 enunciados de Niza están, en español, transcritos literalmente del clasificador en línea de INAPI (tramites.inapi.cl/Trademark/TrademarkNizaClassifier, leído el 2026-09-14). ⛔ No están escritos de memoria, y esa propiedad hay que preservarla si se tocan: es el texto que le dice a un abogado qué cubre una clase, y un enunciado plausible es indistinguible de uno real. ⚠ Hubo que ir a buscarlos fuera: la base no los tiene (clase.nombre son literalmente «Clase 1»… «Clase 45»), ni iris-inapi-knowledge, ni el dato abierto de INAPI (publica 4 conjuntos y ninguno es el clasificador); la OMPI responde Access Denied a una lectura automática y ion.inapi.cl ya no resuelve.

En inglés y portugués van a null, a propósito. La página de INAPI sólo sirve el español (su selector «Buscar en Idioma» aplica a los productos, no al encabezamiento), y el portugués no es lengua oficial de la Clasificación de Niza: no hay fuente que buscar, sólo una traducción que inventar. tituloClase(n, "en") devuelve null y no cae al español — servir castellano bajo Accept-Language: en es peor que no servir nada, porque el consumidor no puede notarlo. TITULOS_PENDIENTES_POR_IDIOMA lo fija un test (es 0, en 45, pt 45).

La edición vigente en Chile es la 13.ª, obligatoria desde el 2026-01-01 (INAPI). El corpus no guarda bajo qué edición se presentó cada marca, así que la etiqueta servida será siempre la de la vigente. Una tabla tampoco lo resolvería: INAPI no publica ese dato por solicitud.

descripcion_etiqueta ya no lleva URLs (ESQ-03): el mapper del legacy usaba etiqueta como respaldo y en el Mongo legacy ese campo es la URL de la imagen, no una descripción. Se saneó a NULL en 34.262 filas y se quitó el respaldo, en ese orden. La entidad central. Clave natural nro_solicitud (D-01); denominacion es un atributo, nunca una clave (D-02). Enriquecida de forma autoritativa por el Buscador; sembrada (solo nulos) por Sheets.

Columna Type Notas
nro_solicitud text PK — clave natural
nro_registro text aparece solo al otorgarse, nullable
denominacion text NOT NULL — el texto de la marca
tipo_signo text CHECK ck_instancia_tipo_signo (14 valores, literales) — ya no es FK (…126)
estado text CHECK ck_instancia_estado (14 valores, literales) — ya no es FK (…126)
tipo_nombre, subtipo_nombre text tipo/subtipo de INAPI (D-14)
fecha_presentacion, fecha_publicacion, fecha_registro, fecha_vencimiento date las cuatro fechas explícitas (D-14)
traduccion, descripcion_etiqueta, protection_description, imagen_url text atributos de detalle (D-14)
renovada_de, renovada_por text self-FK → instancia(nro_solicitud) — cadena de renovación (D-17)
embedding halfvec(1024) vector semántico, índice HNSW
search_vector tsvector generado — FTS en español sobre unaccent(denominacion)
content_hash text idempotencia (INGEST-05)
created_at, updated_at timestamptz

persona — titulares y agentes

Columna Type Notas
id bigint PK (identidad)
pais text pais(codigo)
identificador text RUT (Chile) o id extranjero, nullable
nombre text forma canónica: MAYÚSCULAS con tildes, sin la cláusula de jurisdicción. Es el campo con el que se BUSCA
apellido text 0 filas desde siempre; el pipeline dejó de escribirla (plan 34-02). Sigue publicada en el contrato y su retirada es [BREAK] — ver CHANGELOG-CONTRACT.md
nombre_publicado text la grafía tal cual la escribió INAPI. Es el campo con el que se COTEJA (migraciones …070/…073: la grafía de la fuente se conserva)
observacion text la cláusula de jurisdicción que INAPI escribe pegada al nombre («…SOCIEDAD ORGANIZADA BAJO LAS LEYES DEL ESTADO DE DELAWARE»), retirada de nombre. 1.472 filas en producción, 681 grafías distintas de la MISMA cláusula — es prosa de la fuente, con sus erratas; no es vocabulario controlado
tipo text NOT NULL DEFAULT 'desconocido', CHECK en ('natural','juridica','desconocido'). desconocido es un valor explícito: ~1/3 del corpus no tiene ni RUT ni sufijo societario
tipo_origen text nullable, CHECK en ('rut','sufijo','manual')qué señal produjo tipo. NULL es la ausencia honesta de señal: nunca se inventa un 'manual'
revision boolean NOT NULL DEFAULT false — hay una ambigüedad CONOCIDA en la fila (gemela sospechosa, contradicción interna). No significa que el dato sea malo
revision_motivo text vocabulario cerrado de siete valores (CHECK, migración …093). ⛔ NO se publica en el contrato de lectura: es lenguaje de operador de un corpus que se vende neutral, y crece con cada pasada
region, comuna text opcional (D-16) — códigos CUT sin cero a la izquierda ('13' / '13114'); 99/99999 es el centinela de desconocido/extranjero. No es el domicilio. Desde la migración …092 son FKs de verdad contra los catálogos
search_vector tsvector generado, FTS
created_at timestamptz

Clave única uq_persona_pais_identificador (pais, identificador) donde identificador IS NOT NULL; las personas sin id se reconcilian con mejor esfuerzo por (pais, lower(f_unaccent(nombre))) — insensible a tildes desde la migración …061, que sustituyó el lower(nombre) anterior. Con la grafía canónica de la fase 34 esa igualdad exacta es además la que hace que la MISMA empresa deje de reconciliarse como dos personas distintas.

Árbol territorial (migraciones …090 a …092, fase 34)

persona cuelga de los catálogos territoriales por tres FKs, añadidas NOT VALID y validadas después para no tomar el lock exclusivo durante la comprobación:

FK de → a qué garantiza
fk_persona_comuna persona(comuna)comuna(codigo) un código comunal fuera de catálogo ya no puede almacenarse; entra como NULL + log.warn (D-09), nunca a dead_letter
fk_persona_region persona(pais, region)region(pais_codigo, codigo) compuesta: la región se resuelve dentro de su país, no en un espacio global de códigos
fk_region_pais region(pais_codigo)pais(codigo) cierra el árbol por arriba; region.pais_codigo es text NOT NULL DEFAULT 'CL' con uq_region_pais_codigo como destino de la compuesta

region y comuna no viajan en pareja: cada una se resuelve contra SU catálogo, así que una comuna desconocida no arrastra a su región. Ver CHANGELOG-CONTRACT.md 2026-09-08.

Auditoría de las pasadas de operador (fases 33-35)

Tablas solo-anexadas que existen para que una escritura masiva sea reversible por lote. No son catálogo ni corpus: son el registro de qué se tocó y con qué valor previo.

Desde la fase 35 (D-07 / COLA-07) hay UN solo libro, backfill_ledger. Los tres anteriores siguen en pie con todo su contenido —su retirada va al plan 35-07— pero ya no reciben ni una fila: los seis escritores escriben exclusivamente en el libro nuevo.

Tabla Clave Propósito
backfill_ledger id, UNIQUE (operacion, entidad, clave, lote) migración …116. El libro único de pre-imágenes. Una fila por ESCRITURA, con la pre-imagen como documento jsonb en antes y sin ninguna clave foránea (una cascada se llevaría por delante la evidencia que la tabla existe para guardar). Vocabulario de operacion: nro_registro_centinela, persona_canon, persona_fusion — abierto a propósito, la cuarta pasada no necesita migración. revertido_en distingue una fila revertida de una que nunca se tocó. El contrato de qué claves lleva antes en cada operación vive en la cabecera de la migración, y lo copian los seis escritores y las tres reversiones. Reversiones: db/operator/persona_canon_revert.sql, db/operator/persona_fusion_revert.sql, db/operator/backfill_ledger_revert.sql (genérica, sólo inspecciona y marca)
nro_registro_backfill_audit nro_solicitud migración …077. Absorbido por backfill_ledger bajo el lote absorcion-260909. Ya no se escribe
persona_canon_backfill_audit id migración …094. Una pre-imagen por fila de la canonización de nombres (lote, persona_id, nombre_previo, …). Absorbido por backfill_ledger, ya no se escribe. Lote canon-260908: 396.444 filas
persona_fusion_audit id migración …095, enmendada por la …097 (cuarta acción fusion_sin_puentes, senal_ganador, grupo_tam). Una fila por acción de la fusión de duplicados. Absorbido por backfill_ledger, ya no se escribe. Lote coletilla-260908: 301 movidos + 66 retirados

instancia_persona — puente de rol

Columna Type Notas
instancia_nro_solicitud text PK, → instancia
persona_id bigint PK, → persona
rol text titular | representante | ambas

instancia_clase — clases de Niza

Columna Type Notas
instancia_nro_solicitud text PK, → instancia
clase_numero int PK, → clase (1..45)
descripcion, estado text descripción/estado por clase

anotacion — eventos del Estado-Diario (solo-anexado)

seccion tiene clave foránea a seccion_catalogo.codigo desde la migración …125 (Fase 37, ESQ-10). Antes no había ninguna: 166 de los 668 códigos en uso no existían en el catálogo y 36.101 filas (0,32 %) apuntaban a la nada. ⚠ seccion_catalogo.nombre es ANULABLE a propósito: no existe listado oficial de códigos —INAPI, preguntado el 2026-08-26, pidió «mayores antecedentes»— y no se inventan leyendas. nombre NULL significa «el código existe y no conocemos su leyenda», no «falta por rellenar». De los 166 sembrados, 8 traían el título que INAPI imprime, cosechado del boletín; los otros 158 quedan sin leyenda, y eso es el dato. | Columna | Type | Notas | |---|---|---| | id | bigint | PK (identidad) | | instancia_nro_solicitud | text | → instancia (un padre faltante se convierte en stub/se pone en cuarentena) | | tipo | text | M1..M14, o null para un código no catalogado. Sin FK desde la migración …118 (el catálogo tipo_anotacion se retiró); la garantía vive en los mapeadores. ⛔ Está DENTRO de la clave natural: poblarlo donde hoy va null repartiría los grupos de la clave | | fecha, fecha_vencimiento | date | | | observacion, seccion, seccion_nombre | text | ⛔ observacion está DENTRO de la clave (por su md5): cambiarle un byte no colisiona, duplica | | representante, solicitante | text | Los dos nombres que el boletín imprime en su propia columna (migración …081). TEXTO, no persona: el boletín trae un nombre desnudo y la identidad de una persona es (pais, identificador) | | partes | text | El par de litigantes de la familia C (…081). ⭐ Es el único dato que no publica ninguna otra fuente: ni Sheets ni el Buscador dan el par | | tipo_resolucion | text | La etiqueta «Tipo de resolución» del boletín, verbatim (…082) | | seccion_nombre_especifico | text | El título que el boletín imprime para esa sección concreta | | fuente | text | Qué fuente produjo la fila: estado_diario | buscador. Dos valores, no cuatro desde la migración …120: legacy_mongo se fue a via_importacion (era una VÍA, no un origen) y desconocido se retiró por no tener emisor posible. ⛔ Fuera de la clave natural, del guard y del contentHash | | fuente_origen | text | Qué señal produjo fuente, para que una inferencia no se lea como un hecho: ingesta (lo afirmó el emisor) | evidencia (una columna que sólo escribe el diario) | ventana (la pasada de importación o la ventana de sync_run, …120) | codigo (la única que puede equivocarse). Prioridad al clasificar: evidencia > ventana > codigo | | via_importacion | text | POR DÓNDE entró la fila, que es distinto de quién la escribió (…120): legacy_mongo = vino en el dump del Mongo legacy; null = ingesta directa. ⭐ Existe porque el dump legacy era una vía para dos fuentes, y confundirlas costaba 369.171 filas de boletín en un WHERE | | evento_id | bigint | QUÉ FILAS DESCRIBEN EL MISMO HECHO (…122). Es el min(id) del grupo, NO el id de la fila canónica: eso separa «qué filas son el mismo hecho» (un backfill reversible por lote) de «cuál se enseña» (una regla de lectura), y la segunda ya cambió una vez. ⭐ Al ser el mínimo es estable bajo crecimiento: los id son monótonos, así que una fila nueva nunca baja el mínimo del grupo al que llega. ⛔ NULL significa «esta fila es su propio evento» y lo es en el 89,8 % del corpus: toda lectura agrupa por coalesce(evento_id, id), nunca por evento_id a secas. ⛔ Fuera de la clave natural, del guard y del contentHash; no lo escribe ninguna de las dos copias de upsertAnotacion | | evento_metodo | text | QUÉ SEÑAL agrupó las filas: equivalencia_seccion (misma marca, mismo día, y las dos secciones forman un par de seccion_equivalencia con confirmada — 297.805 eventos / 595.853 filas) | a_favor_de (misma (marca, fecha, seccion), exactamente dos filas, una lleva el prefijo «A Favor de:» y las dos prosas coinciden al quitarlo — 279.008 eventos / 558.016 filas) | manual. Misma doctrina que fuente_origen: que una inferencia no se lea como un hecho. ⭐ Es lo que permite revertir una familia sin la otra dentro de un mismo lote | | created_at | timestamptz | ⚠ Es la fecha de CREACIÓN de la fila, no la de su última escritura: hay filas creadas por el import legacy en junio y enriquecidas después por nuestro parser |

Clave de deduplicación uq_anotacion_event_seccion (instancia_nro_solicitud, tipo, fecha, seccion, md5(coalesce(observacion,''))), con NULLS NOT DISTINCT — un evento repetido es un DO NOTHING limpio (los eventos nunca mutan).

!!! danger "⛔ Esta página decía lo contrario hasta el 2026-09-10, y era el defecto YA ARREGLADO" Aquí figuraba la clave sin seccion, con un aviso que decía «seccion NO está en la clave, y eso descarta filas»: que para un código no-M sin observación la clave se reducía a (solicitud, fecha) y la segunda fila del día se perdía sin dejar rastro.

**Era cierto, y la migración `…072` lo arregló** — el agujero llegó a afectar al 71 % del corpus
con la clave reducida. La página se quedó describiendo el problema en vez del arreglo, así que
durante semanas dijo a quien la leyera que el corpus pierde filas en silencio. **Hoy `seccion`
está DENTRO de la clave** y ese caso no existe.

📌 La lección, y por eso se deja escrita en vez de borrar el párrafo: una página que documenta un
defecto hay que volver a mirarla **el día que el defecto se arregla**, no sólo cuando cambia el
esquema.

Medido el 2026-08-24 sobre 33 payloads crudos del Buscador (13.856 marcas, 56.133 anotaciones):
**3.016 filas descartadas con una sección distinta a la guardada — 5,37 %**, 19,9 % de las
marcas afectadas. El par más frecuente que importa es `012` + `OPO08` (131 casos): una
presentación de oposición que desaparece porque un fin de plazo cayó el mismo día.

**La `observacion` SÍ está en la clave**, así que dos filas con textos distintos no pueden
colisionar: el descarte nunca pierde prosa (medido: 0 de 3.347).

anotacion_persona — quién aparece en cada evento (migración …121)

Columna Type Notas
id bigint PK (identidad)
anotacion_id bigint anotacion con ON DELETE CASCADE. ⛔ El CASCADE es un cinturón que por política nunca se dispara: esta fase prohíbe el DELETE sobre anotacion
rol text representante | solicitante | oponente | solicitante_oposicion. Vocabulario cerrado, y los cuatro salen del boletín — no de un flujo de trabajo
posicion smallint El orden dentro del rol; partes trae dos nombres
nombre_texto text EL TESTIGO, verbatim. NOT NULL: es la única prueba de qué publicó INAPI, y sobrevive aunque no se pueda enlazar
nombre_clave text El plegado con el que se buscó, calculado en SQL con la misma expresión que la fusión de la Fase 34
persona_id bigint persona. NULLABLE a propósito
metodo text puente_marca | persona_unica | manual, o null si no se resolvió
candidatos smallint Cuántas personas casaban la clave, se haya resuelto o no

Por qué persona_id es nullable, que es la decisión de diseño entera. El nombre no identifica: el boletín publica «SARGENT & KRAHN» y en persona hay 11 filas con ese nombre. Medido, el 62,6 % de las filas de representante que casan algún nombre casan 2+ personas (3.945 nombres, los estudios jurídicos). Una fila que no se puede resolver se guarda visible — con su testigo y su recuento— en vez de adivinarse.

Resultado de la pasada: representante 83,0 % resuelto · solicitante 86,8 %, y el peldaño puente_marca (la candidata que ya está vigente en esa misma marca) resuelve 225.037 filas que el nombre único sola habría dejado en null.

No es instancia_persona y no puede serlo: la PK de aquélla es (instancia, persona) sin rol. Una persona que sea representante en una anotación y solicitante en otra de la misma marca no cabe ahí. Ésta es por evento, no por marca.

No la expone el contrato de lectura todavía — publicarla es un cambio de contrato aparte.


Catálogos

Tabla PK Otros
estado codigo nombre — conjunto controlado de estados
tipo_signo codigo nombre
pais codigo nombre — conjunto ISO completo + códigos obsoletos
clase numero nombre — clases de Niza, CHECK (numero BETWEEN 1 AND 45)
region codigo nombre, pais_codigopais(codigo); uq_region_pais_codigo (pais_codigo, codigo). 17 filas, incluida la 16 (Ñuble)
seccion_catalogo codigo nombre, tipo, y desde la …118 fuentes text[] (qué fuentes emiten ese código) y lleva_partes (representante | partes | ninguna). ⚠ Incompleto a propósito y medido: anotacion usa 667 códigos distintos y 165 no tienen fila aquí, así que NO sirve como filtro — dejaría fuera filas reales (ESQ-10 de la fase 37)
seccion_equivalencia (codigo_a, codigo_b) «estos dos códigos son el mismo hecho», N:N con orientación canónica CHECK (codigo_a < codigo_b) y su evidencia al lado (…119): una equivalencia sin la cifra que la sostiene no entra
comuna codigo nombre, region_codigoregion. 361 filas (346 + 15 cosechadas de los CSV retenidos). Las 21 comunas 84xx son de la región 16, no de la 8 — Ñuble es la única región cuyas comunas no empiezan por su propio número. Centinelas X999 («comuna desconocida DE la región X»), incluido el 16999 que faltaba

Un valor de catálogo desconocido se acepta como null + se registra en log (D-09) — un nuevo valor de INAPI nunca detiene la ingesta.


Ingesta / operaciones

Tabla Clave Propósito
sync_state source (PK) cursor de reanudación por fuente (cursor jsonb) + estadísticas de movimiento (migr. …124): last_movement_at (MONÓTONO — se escribe con greatest, porque un backfill de un boletín viejo sella occurred_at en el pasado y no debe hacer retroceder la frescura) y movement_count (ACUMULATIVO: cuántos cambios ha producido la fuente desde siempre, no count(movement_log) — la retención borra filas y nunca toca este contador). Las mantiene recordMovement en la MISMA transacción que escribe el movimiento
movement_log id feed de cambios solo-anexado (entity_type, entity_id, change_type, source, occurred_at, content_hash) — alimenta la lista de trabajo del Buscador
raw_source id bytes en crudo retenidos (storage_path bajo RAW_STORAGE_DIR, content_type, size) — re-derivabilidad (D-10). UNIQUE (source, sha256) + veces_visto/ultima_vez desde la fase 35 (D-07)
dead_letter id cuarentena (source, reason, raw_source_idraw_source, payload jsonb) — no aborta (D-07)
sync_run id historial de ejecuciones (source, tarea, started_at, finished_at, status ok\|failed\|running, processed, changed, encoladas, quarantined, requests_used, items_charged, watch_watermark, error) — OBS-03 · columna vertebral de la contabilidad desde la fase 35 (D-04)
sync_run_reason (sync_run_id, reason) cohortes de una corrida: cuántas marcas entraron por cada motivo. Se escribe cada noche; su sustituto (GROUP BY regla, sync_run_id sobre buscador_cola) NO es alcanzable hasta que sync_run_id se cablee a través de Pipeline.extract
buscador_cola id la cola del Buscador CON HISTORIA (D-01): una fila por ENCOLADO (nro_solicitud, regla, encolada_en, drenada_en, resultado, peticiones, detalle). Pendiente = drenada_en IS NULL; el drenaje ESTAMPA, nunca borra. Único parcial (nro_solicitud, regla) WHERE drenada_en IS NULL
estado_diario_dia id un registro por día y BOLETÍN del Estado Diario (fecha, boletin tipo2\|altas, anotaciones), con uq_estado_diario_dia_fecha_boletin (D-05)
backfill_ledger id libro ÚNICO de pre-imágenes de las pasadas de operador (operacion, entidad, clave, lote, antes jsonb, aplicado_en, revertido_en), UNIQUE (operacion, entidad, clave, lote) y sin claves foráneas a propósito (D-07)

Fase 35 — retirada. buscador_diff_queue, buscador_revisit_state, buscador_coherencia_state, sheets_registro_new_state y watch_sweep_signal ya no existen: las retiró db/migrations/20260909000117_retirada_cola_y_estados.sql. Su historia vive en buscador_cola (sembrada por la …111 con resultado = 'migrado') y en sync_run.watch_watermark (…114). Los tres libros de pre-imágenes por operación (persona_fusion_audit, persona_canon_backfill_audit, nro_registro_backfill_audit) siguen en pie: la …116 los absorbió en backfill_ledger y se conservan como segunda copia hasta que cierre la fase 34.


Control de acceso

Tabla Clave Propósito
consumer id organización/tenant (name UNIQUE)
api_key id consumer_idconsumer; key_hash (sha256, UNIQUE — nunca en texto plano), prefix, scopes text[], monthly_quota int (>= 0), expires_at, rotated_at, revoked_at

Scopes: brands:read (/v1/brands, MCP search_brands/get_brand_detail), insights:read (/v1/freshness, /v1/sync-runs). Scopes vacíos = sin restricción. Los contadores de cuota viven en Redis (compartidos REST ↔ MCP), no en Postgres, de modo que la ruta de lectura se mantiene solo-SELECT.


Índices clave

  • FTS: GIN sobre instancia.search_vector y persona.search_vector (español, insensible a acentos vía unaccent).
  • Fuzzy: pg_trgm GIN/GiST sobre la denominación para búsqueda tolerante a errores tipográficos.
  • Vector: HNSW sobre instancia.embedding (halfvec, coseno) para similitud semántica.
  • Reconcile: parcial idx_persona_noident_reconcile_unaccent (pais, lower(f_unaccent(nombre))) WHERE identificador IS NULL (migración …061; sustituyó al lower(nombre) sensible a tildes). Sus dos espejos de prefijo para las pasadas de operador: idx_persona_noident_pref50 (…089) y idx_persona_noident_pub_pref50 (…094), con la MISMA expresión plegada sobre left(…, 50).
  • Nombre de persona: idx_persona_nombre_trgm (…047) sobre f_unaccent(lower(coalesce(nombre,'') || ' ' || coalesce(apellido,''))). ⚠ apellido está DENTRO de la expresión de éste y de los dos tsvector (…004, …058), así que el predicado de búsqueda de personas está escrito carácter a carácter para casarla. Retirar la columna obliga a recrear los tres índices o la búsqueda cae a seq-scan sobre 396 k filas.
  • Claves naturales / dedup: uq_anotacion_event_seccion, uq_persona_pais_identificador, más las PKs de las tablas anteriores.

Las migraciones se aplican vía dbmate up; el DDL completo (índices, columnas generadas, orden de FK, configuración de FTS en español) vive en ../db/migrations/.