Yupana

El métodoCómo está hecho

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
nombretextsí—
nivelNivel de autoridad. Obligatorio y sin valor por defecto (ADR-0006).nivel_autoridadsí—
tipotipo_entidadsí—
ubigeochar(6)——
pliegotext——
rucchar(11)——
pte_id_entidadParámetro del conector del PTE, que se cosecha entidad por entidad.text——
poblacioninteger——
presupuesto_anualnumeric(18, 2)——
activa_desdedatesí—
activa_hastadate——

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
ColumnaTipoObligatoriaApunta a
idpktextsí—
nombretextsí—
dominiotextsí—
friccionEsfuerzo de cosecha, no valor: A abierta, B semi, C controlada.friccion_fuentesí—
licenciatextsí—
cadenciatextsí—
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).

ColumnaTipoObligatoriaApunta a
idpkbigintsí—
fuente_idtextsífuente
urltextsí—
parametrosjsonbsí—
hash_contenidochar(64)sí—
hash_formachar(64)sí—
bytesbytea——
bytes_uritext——
fecha_capturatimestamptzsí—
disparadordisparador_capturasí—
operadortext——

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
tipotipo_nodosí—
clave_naturaltextsí—
atributosjsonbsí—
entidad_idbigint—entidad
captura_idDe qué captura salió este nodo. NOT NULL: no hay hechos sin procedencia.bigintsícaptura
fecha_datodate——
fecha_capturatimestamptzsí—

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
origen_idbigintsínodo
destino_idbigintsínodo
tipotipo_aristasí—
atributosjsonbsí—
captura_idbigintsícaptura
reglatextsí—
regla_versiontextsí—
confianzanumeric(4,3)sí—
inferidabooleansí—
fecha_datodate——
fecha_capturatimestamptzsí—

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
regla_idtextsí—
regla_versiontextsí—
entidad_idbigintsíentidad
periododaterangesí—
severidadsmallintsí—
valorjsonbsí—
umbraljsonbsí—
denominadorjsonb——
criterio_fuentetext——
estadoestado_senalsí—
fecha_calculotimestamptzsí—

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
senal_idbigintsísenal
nodo_idbigint—nodo
arista_idbigint—arista
captura_idbigintsícaptura
descripciontextsí—

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
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
senal_idbigintsísenal
revisor_idbigintsí—
asignada_entimestamptzsí—
enviada_entimestamptz——
camposjsonb——
aparto_por_conflictobooleansí—

Restricciones

  • revision_unica UNIQUE

    senal_id, revisor_id

publicacion4 columnas · 1 clave ajena
ColumnaTipoObligatoriaApunta a
senal_idpkbigintsísenal
enunciadotextsí—
cerrada_portextsí—
publicada_entimestamptzsí—
indicador_publicado14 columnas · 2 restricciones · 3 claves ajenas
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
indicador_idtextsí—
entidad_idbigintsíentidad
periododaterangesí—
fuente_idtextsífuente
captura_idbigintsícaptura
regla_versiontextsí—
fecha_calculotimestamptzsí—
valorjsonbsí—
criterio_fuentetext——
paresjsonb——
enunciadotextsí—
sin_senalesbooleansí—
reglas_corridastext[]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.

ColumnaTipoObligatoriaApunta a
idpkbigintsí—
fuente_idtextsífuente
tipotipo_bitacorasí—
captura_antesbigint—captura
captura_despuesbigint—captura
detallejsonbsí—
detectado_entimestamptzsí—
publicobooleansí—

índices: bitacora_fuente_idx

0003_senales.sql:140 →
rectificacion9 columnas · 1 restricción · 1 clave ajena
ColumnaTipoObligatoriaApunta a
idpkbigintsí—
indicador_publicado_idbigintsíindicador_publicado
solicitada_portextsí—
recibida_entimestamptzsí—
acusada_entimestamptz——
resuelta_entimestamptz——
resultadoresultado_rectificacion——
detalle_publicotextsí—
descargo_urltext——

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.

ColumnaTipoObligatoriaApunta a
idpkbigintsí—
fuente_idtextsífuente
parametrosjsonbsí—
motivotextsí—
senal_idbigint—senal
estadotextsí—
operadortext——
captura_idbigint—captura
creado_entimestamptzsí—
resuelto_entimestamptz——

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
  1. 1nacional
  2. 2regional
  3. 3local

Atributo de la entidad, no producto aparte: un detector se escribe una vez y se parametriza por entidad (ADR-0006).

tipo_entidad
  1. 1ministerio
  2. 2organismo
  3. 3gobierno_regional
  4. 4municipalidad_provincial
  5. 5municipalidad_distrital
friccion_fuente
  1. 1A
  2. 2B
  3. 3C

Mide esfuerzo de cosecha, no valor: una fuente controlada no vale más por ser más difícil.

disparador_captura
  1. 1programado
  2. 2watcher
  3. 3webhook
  4. 4asistida

«asistida» es la única que exige un operador humano, y por eso la captura registra quién fue (ADR-0008).

tipo_nodo
  1. 1entidad
  2. 2persona
  3. 3empresa
  4. 4contrato
  5. 5norma
  6. 6cargo
  7. 7visita
  8. 8aporte
tipo_arista
  1. 1adjudico
  2. 2gano
  3. 3postulo
  4. 4ejercio
  5. 5cargo_en
  6. 6designa
  7. 7emitio
  8. 8firmo
  9. 9representa
  10. 10vinculada_declarada
  11. 11visito
  12. 12visita_a
  13. 13aporto
  14. 14aporte_a
  15. 15misma_persona
  16. 16misma_empresa
  17. 17aporto_y_despues_contrato
  18. 18ceso_y_despues_contrato
  19. 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
  1. 1nueva
  2. 2revision
  3. 3verificada
  4. 4publicada
  5. 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
  1. 1cambio_forma
  2. 2dato_desaparecido
  3. 3campo_nuevo
  4. 4campo_retirado
  5. 5fuente_caida
resultado_rectificacion
  1. 1medicion_corregida
  2. 2fuente_discrepante
  3. 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.

Tablayupana_runneryupana_web
entidadSELECT · INSERT · UPDATESELECT
fuenteSELECT · INSERT · UPDATESELECT
capturaSELECT · INSERTsin permiso
nodoSELECT · INSERT · UPDATEsin permiso
aristaSELECT · INSERT · UPDATEsin permiso
senalSELECT · INSERT · UPDATESELECT
senal_evidenciaSELECT · INSERT · UPDATEsin permiso
revisionsin permisoSELECT · INSERT · UPDATE
publicacionsin permisoSELECT
indicador_publicadoSELECT · INSERT · UPDATESELECT
bitacoraSELECT · INSERT · UPDATESELECT
rectificacionsin permisoSELECT
pedido_asistidosin permisoSELECT · 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.

Más en «Cómo está hecho»

Todo el método →