Modelo de datos
El esquema, tabla por tabla
No describe el esquema: lo lee del SQL aplicado.
Aquí no hay datos de demostración: hay un esquema aplicado y vacío
A diferencia de los tableros, lo que se ve en esta página es el artefacto real: las cuatro migraciones del repositorio, que desde el 19 de septiembre de 2026 corren sobre una base de verdad (batch 1.1). Las tablas existen, y todas están vacías.
Las dos cosas son ciertas a la vez, y conviene no confundirlas: crear una tabla no recolecta nada. Lo que recolecta es encender un conector, y eso exige antes el registro del banco de datos ante la ANPD (batch 0.1). El esquema está listo para recibir; el registro decide cuándo se escribe la primera fila.
Vive en un esquema propio dentro de un Postgres compartido con los otros productos del ecosistema. Por eso cada migración fija su search_path y los permisos se conceden sobre ese esquema: concederlos sobre public habría dado acceso a lo que no es de Yupana. Eso se desvía de ADR-0002, que decidió una base separada —un esquema aparte aísla los nombres, no el riesgo—, y está anotado como decisión pendiente en lugar de darse por buena.
- Tablas
- 13
- Columnas
- 119
- Restricciones con nombre
- 15
- Tipos enumerados
- 9
Las cuatro migraciones
Cada una corre dentro de una transacción y registra su propia versión en la tabla migracion. Sin lo primero, una migración que falla a la mitad deja el esquema en el peor estado posible: ni el anterior ni el nuevo.
0001_base
entidades, fuentes y almacén crudo
184 líneas · transaccional: sí · registra su versión: sí · db/migraciones/0001_base.sql
entidad12 columnas · 3 restricciones
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| nombre | text | sí | — |
| nivelNivel de autoridad. Obligatorio y sin valor por defecto (ADR-0006). | nivel_autoridad | sí | — |
| tipo | tipo_entidad | sí | — |
| ubigeo | char(6) | — | — |
| pliego | text | — | — |
| ruc | char(11) | — | — |
| pte_id_entidadParámetro del conector del PTE, que se cosecha entidad por entidad. | text | — | — |
| poblacion | integer | — | — |
| presupuesto_anual | numeric(18, 2) | — | — |
| activa_desde | date | sí | — |
| activa_hasta | date | — | — |
Restricciones
entidad_vigencia_coherente CHECK
activa_hasta IS NULL OR activa_hasta >= activa_desde
entidad_ruc_unico UNIQUE
ruc
entidad_pte_unico UNIQUE
pte_id_entidad
índices: entidad_nivel_tipo_idx · entidad_ubigeo_idx · entidad_pliego_idx
0001_base.sql:65 →fuente6 columnas
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | text | sí | — |
| nombre | text | sí | — |
| dominio | text | sí | — |
| friccionEsfuerzo de cosecha, no valor: A abierta, B semi, C controlada. | friccion_fuente | sí | — |
| licencia | text | sí | — |
| cadencia | text | sí | — |
captura11 columnas · 3 restricciones · 1 clave ajena
Almacén crudo inmutable. No hay UPDATE ni DELETE: la inmutabilidad se hace cumplir con permisos, no con convención (ver 0004_permisos.sql).
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| fuente_id | text | sí | fuente |
| url | text | sí | — |
| parametros | jsonb | sí | — |
| hash_contenido | char(64) | sí | — |
| hash_forma | char(64) | sí | — |
| bytes | bytea | — | — |
| bytes_uri | text | — | — |
| fecha_captura | timestamptz | sí | — |
| disparador | disparador_captura | sí | — |
| operador | text | — | — |
Restricciones
captura_operador_solo_si_asistida CHECK
(disparador = 'asistida') = (operador IS NOT NULL)
captura_tiene_bytes CHECK
bytes IS NOT NULL OR bytes_uri IS NOT NULL
captura_unica UNIQUE
fuente_id, url, parametros, hash_contenido
índices: captura_fuente_fecha_idx · captura_forma_idx
0001_base.sql:116 →0002_grafo
el grafo canónico
110 líneas · transaccional: sí · registra su versión: sí · db/migraciones/0002_grafo.sql
nodo8 columnas · 1 restricción · 2 claves ajenas
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| tipo | tipo_nodo | sí | — |
| clave_natural | text | sí | — |
| atributos | jsonb | sí | — |
| entidad_id | bigint | — | entidad |
| captura_idDe qué captura salió este nodo. NOT NULL: no hay hechos sin procedencia. | bigint | sí | captura |
| fecha_dato | date | — | — |
| fecha_captura | timestamptz | sí | — |
Restricciones
nodo_unico UNIQUE
tipo, clave_natural, captura_id
índices: nodo_tipo_clave_idx · nodo_entidad_idx
0002_grafo.sql:57 →arista12 columnas · 2 restricciones · 3 claves ajenas
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| origen_id | bigint | sí | nodo |
| destino_id | bigint | sí | nodo |
| tipo | tipo_arista | sí | — |
| atributos | jsonb | sí | — |
| captura_id | bigint | sí | captura |
| regla | text | sí | — |
| regla_version | text | sí | — |
| confianza | numeric(4,3) | sí | — |
| inferida | boolean | sí | — |
| fecha_dato | date | — | — |
| fecha_captura | timestamptz | sí | — |
Restricciones
arista_sin_bucle CHECK
origen_id <> destino_id
arista_confianza_coherente CHECK
(inferida = false AND confianza = 1) OR (inferida = true AND confianza < 1)
Lo observado lleva confianza 1; lo inferido, menos. Mezclarlas haría que una inferencia viaje por el sistema con apariencia de hecho.
índices: arista_origen_idx · arista_destino_idx · arista_inferida_idx
0002_grafo.sql:76 →0003_senales
señales, indicadores publicados, bitácora y rectificaciones
196 líneas · transaccional: sí · registra su versión: sí · db/migraciones/0003_senales.sql
senal12 columnas · 1 restricción · 1 clave ajena
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| regla_id | text | sí | — |
| regla_version | text | sí | — |
| entidad_id | bigint | sí | entidad |
| periodo | daterange | sí | — |
| severidad | smallint | sí | — |
| valor | jsonb | sí | — |
| umbral | jsonb | sí | — |
| denominador | jsonb | — | — |
| criterio_fuente | text | — | — |
| estado | estado_senal | sí | — |
| fecha_calculo | timestamptz | sí | — |
Restricciones
senal_unica UNIQUE
regla_id, regla_version, entidad_id, periodo
índices: senal_entidad_idx · senal_regla_idx
0003_senales.sql:44 →senal_evidencia6 columnas · 1 restricción · 4 claves ajenas
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| senal_id | bigint | sí | senal |
| nodo_id | bigint | — | nodo |
| arista_id | bigint | — | arista |
| captura_id | bigint | sí | captura |
| descripcion | text | sí | — |
Restricciones
evidencia_apunta_a_algo CHECK
nodo_id IS NOT NULL OR arista_id IS NOT NULL
índices: senal_evidencia_senal_idx
0003_senales.sql:67 →revision7 columnas · 1 restricción · 1 clave ajena
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| senal_id | bigint | sí | senal |
| revisor_id | bigint | sí | — |
| asignada_en | timestamptz | sí | — |
| enviada_en | timestamptz | — | — |
| campos | jsonb | — | — |
| aparto_por_conflicto | boolean | sí | — |
Restricciones
revision_unica UNIQUE
senal_id, revisor_id
publicacion4 columnas · 1 clave ajena
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| senal_idpk | bigint | sí | senal |
| enunciado | text | sí | — |
| cerrada_por | text | sí | — |
| publicada_en | timestamptz | sí | — |
indicador_publicado14 columnas · 2 restricciones · 3 claves ajenas
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| indicador_id | text | sí | — |
| entidad_id | bigint | sí | entidad |
| periodo | daterange | sí | — |
| fuente_id | text | sí | fuente |
| captura_id | bigint | sí | captura |
| regla_version | text | sí | — |
| fecha_calculo | timestamptz | sí | — |
| valor | jsonb | sí | — |
| criterio_fuente | text | — | — |
| pares | jsonb | — | — |
| enunciado | text | sí | — |
| sin_senales | boolean | sí | — |
| reglas_corridas | text[] | sí | — |
Restricciones
indicador_unico UNIQUE
indicador_id, entidad_id, periodo, regla_version
indicador_vacio_declara_reglas CHECK
sin_senales = false OR cardinality(reglas_corridas) > 0
Un periodo sin señales sólo es publicable si dice qué reglas se corrieron: es lo que distingue «se midió y no había nada» de «no se midió».
índices: indicador_entidad_idx
0003_senales.sql:108 →bitacora8 columnas · 3 claves ajenas
«Este dato estaba aquí el 3 de marzo y ya no está» es un hallazgo, y las dos capturas son la prueba. Por qué desapareció exige otra fuente.
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| fuente_id | text | sí | fuente |
| tipo | tipo_bitacora | sí | — |
| captura_antes | bigint | — | captura |
| captura_despues | bigint | — | captura |
| detalle | jsonb | sí | — |
| detectado_en | timestamptz | sí | — |
| publico | boolean | sí | — |
índices: bitacora_fuente_idx
0003_senales.sql:140 →rectificacion9 columnas · 1 restricción · 1 clave ajena
| Columna | Tipo | Obligatoria | Apunta a |
|---|---|---|---|
| idpk | bigint | sí | — |
| indicador_publicado_id | bigint | sí | indicador_publicado |
| solicitada_por | text | sí | — |
| recibida_en | timestamptz | sí | — |
| acusada_en | timestamptz | — | — |
| resuelta_en | timestamptz | — | — |
| resultado | resultado_rectificacion | — | — |
| detalle_publico | text | sí | — |
| descargo_url | text | — | — |
Restricciones
rectificacion_resuelta_tiene_resultado CHECK
(resuelta_en IS NULL) = (resultado IS NULL)
pedido_asistido10 columnas · 3 claves ajenas
Sólo para barreras TÉCNICAS que exigen interacción humana. No para el CEJ, donde la barrera es legal: ahí el instrumento se detiene.
0004_permisos
los roles, y la inmutabilidad hecha cumplir
81 líneas · transaccional: sí · registra su versión: sí · db/migraciones/0004_permisos.sql
No crea tablas: concede los permisos. Es la migración más corta y la que más sostiene —«nada se pisa: se acumula» es una convención hasta que se escribe como permiso—. Está más abajo, en la matriz.
La función del esquema
Casi toda la lógica vive en el runner, en Python (ADR-0004). Lo que baja al esquema es lo que tiene que dar el mismo resultado desde cualquier consulta.
pares_de_entidad(p_entidad_id bigint, p_factor numeric DEFAULT 2.0)
retorna TABLE (par_id bigint)
Conjunto de pares comparables de una entidad: mismo nivel, mismo tipo y población dentro de un factor. El factor es parámetro y no constante, porque cada indicador comparativo declara cómo armó su conjunto (ADR-0006).
0001_base.sql:156 →Sin comparación con pares, el nivel local no se puede medir: un 12 % de ejecución no dice nada hasta saber la mediana de sus pares. Y es también el límite del nivel local —ningún detector emite señal por ausencia de dato a secas: emite por inconsistencia entre dos fuentes que publicaron, o por ausencia obligatoria con pares comparables que sí publican—. Los cinco límites.
Los tipos enumerados
Estos valores y los de lib/vocabulario.ts son la misma lista. El esquema es la fuente de verdad (ADR-0004): un generador emite el JSON intermedio y hay una prueba en cada lenguaje que compara las dos puntas. Agregar un valor al SQL y no regenerar rompe el CI, que es exactamente para lo que está.
- nivel_autoridad
- 1nacional
- 2regional
- 3local
Atributo de la entidad, no producto aparte: un detector se escribe una vez y se parametriza por entidad (ADR-0006).
- tipo_entidad
- 1ministerio
- 2organismo
- 3gobierno_regional
- 4municipalidad_provincial
- 5municipalidad_distrital
- friccion_fuente
- 1A
- 2B
- 3C
Mide esfuerzo de cosecha, no valor: una fuente controlada no vale más por ser más difícil.
- disparador_captura
- 1programado
- 2watcher
- 3webhook
- 4asistida
«asistida» es la única que exige un operador humano, y por eso la captura registra quién fue (ADR-0008).
- tipo_nodo
- 1entidad
- 2persona
- 3empresa
- 4contrato
- 5norma
- 6cargo
- 7visita
- 8aporte
- tipo_arista
- 1adjudico
- 2gano
- 3postulo
- 4ejercio
- 5cargo_en
- 6designa
- 7emitio
- 8firmo
- 9representa
- 10vinculada_declarada
- 11visito
- 12visita_a
- 13aporto
- 14aporte_a
- 15misma_persona
- 16misma_empresa
- 17aporto_y_despues_contrato
- 18ceso_y_despues_contrato
- 19visito_y_despues_gano
Las últimas tres son compuestas: las escribe el cruce (capa 07) con su regla y su confianza, nunca un detector.
- estado_senal
- 1nueva
- 2revision
- 3verificada
- 4publicada
- 5descartada
El orden es la compuerta: de «nueva» a «publicada» no se salta ningún paso, y sólo la capa 11b firma el último.
- tipo_bitacora
- 1cambio_forma
- 2dato_desaparecido
- 3campo_nuevo
- 4campo_retirado
- 5fuente_caida
- resultado_rectificacion
- 1medicion_corregida
- 2fuente_discrepante
- 3dato_cambio_en_fuente
Quién puede escribir qué
La frontera de ADR-0001 —el runner escribe, la web lee— no es una convención del código: son permisos de Postgres. La matriz se resuelve en el orden de la migración, porque el orden es el que manda: un REVOKE ALL seguido de un GRANT SELECT deja la tabla en sólo lectura, y al revés la deja sin nada.
| Tabla | yupana_runner | yupana_web |
|---|---|---|
| entidad | SELECT · INSERT · UPDATE | SELECT |
| fuente | SELECT · INSERT · UPDATE | SELECT |
| captura | SELECT · INSERT | sin permiso |
| nodo | SELECT · INSERT · UPDATE | sin permiso |
| arista | SELECT · INSERT · UPDATE | sin permiso |
| senal | SELECT · INSERT · UPDATE | SELECT |
| senal_evidencia | SELECT · INSERT · UPDATE | sin permiso |
| revision | sin permiso | SELECT · INSERT · UPDATE |
| publicacion | sin permiso | SELECT |
| indicador_publicado | SELECT · INSERT · UPDATE | SELECT |
| bitacora | SELECT · INSERT · UPDATE | SELECT |
| rectificacion | sin permiso | SELECT |
| pedido_asistido | sin permiso | SELECT · INSERT · UPDATE |
Esta matriz sale del SQL. Al aplicar el esquema se comprobó contra la base ya creada, preguntándole tabla por tabla con has_table_privilege en lugar de leer el archivo: las dos coinciden fila por fila.
El almacén crudo no se puede modificar
Sobre captura el runner tiene SELECT · INSERT y nada más: la migración le quita UPDATE, DELETE y TRUNCATE de forma explícita. Es lo que vuelve reverificable una señal de hace dos años, y la razón por la que la captura sobrevive a que la fuente cambie el dato.
2 tablas sin ningún escritor
publicacion · rectificacion — ningún rol de este esquema puede insertar ahí, y no es un olvido: son las tablas de la capa 11b. Publicar lo firma una persona nombrada, y el registro de rectificaciones lo lleva quien responde por ellas. Ese rol no existe en la base porque todavía nadie lo designó: es el batch 0.2, y hasta entonces no hay publicación posible aunque todo lo demás esté construido.
- yupana_runner
- el runner: cosecha, cruza y detecta (ADR-0001)
- yupana_web
- la web: lee vistas precalculadas y atiende dos colas
- yupana_admin
- migra, y no aparece en la matriz porque esta migración no le concede nada: no se usa para operar.
Lo que la base rechaza
Que una migración corra sin error no dice que las restricciones hagan algo. Al aplicar el esquema se intentaron cinco inserciones prohibidas, dentro de una transacción que termina deshaciéndose. La base rechazó las cinco:
Una arista observada con confianza parcial
arista_confianza_coherente
Lo observado lleva confianza 1. Si no, una inferencia viaja con apariencia de hecho.
Un periodo sin señales que no declara qué reglas se corrieron
indicador_vacio_declara_reglas
Es lo que distingue «se midió y no había nada» de «no se midió».
Un nodo sin la captura de la que salió
nodo.captura_id NOT NULL
No existe un hecho sin procedencia, y no se puede insertar uno por descuido.
Una captura programada con operador
captura_operador_solo_si_asistida
Sólo una consulta asistida tiene detrás a una persona, y entonces se registra quién.
Una entidad sin nivel de autoridad
entidad.nivel NOT NULL, sin DEFAULT
El nivel se declara (ADR-0006). Un valor por defecto reintroduciría el supuesto que esa decisión elimina.
Después de la sonda las tablas siguen vacías: la transacción se deshizo entera, que es como se prueba una restricción sin dejar una fila inventada en una base que va a publicar.
El mismo esquema, dibujado: el diagrama entidad-relación del corpus → Lo genera el mismo lector, en Mermaid, y GitHub lo dibuja solo.