Published

HANDOFF — Index: separar la gobernanza (PG) del formato (R2 + Lakekeeper)

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

HANDOFF — Index: separar la gobernanza (PG) del formato (R2 + Lakekeeper)

Lee esto primero. Handoff vivo, act. 2026-07-30 (sesión 2). main = 38430a4 + lo de esta sesión.

La sesión 1 hizo dos cosas: nombró y delimitó el tercer pilar del Warehouse —Index, la gobernanza que vive en Postgres— y empezó a levantarlo de verdad, con código medido contra la infraestructura desplegada. 32 commits.

La sesión 2 validó las prioridades contra el runtime vivo y las reordenó (§7): la compactación baja, el carrier de procedencia sube. Y cerró I·3a.

Lo más valioso que hay aquí no es el código: son las premisas que resultaron FALSAS al medirlas. Diez de ellas, listadas en §5. Cada una habría costado una iteración perdida. Si sólo lees una sección, que sea esa.


1 · Qué es Index, en una frase

Index es la base de datos de gobernanza del Warehouse: el registro de todo lo que el catálogo Iceberg no puede saber, y de todo lo que hace que un dato sea un producto y no un montón de ficheros Parquet.

Los tres pilares, con sus nombres definitivos:

                     Superficies ──▶ JUNCTION (la puerta única)
      ┌──────────────────────────────────┼──────────────────────────────────┐
      ▼                                  ▼                                  ▼
  ① BYTES                        ② FORMATO + CATÁLOGO              ③ INDEX
    Parquet en R2                   Iceberg + Lakekeeper              Postgres
    (abierto, no nuestro)           (la cintura estrecha)             (propietario)
                                    qué tablas, qué snapshot,         de quién es, de dónde
                                    qué esquema físico                viene, quién puede verlo,
                                                                      de qué calidad es

Junction no es un pilar — es la puerta delante de los tres. Lakekeeper no es un peer de Index — es la autoridad sobre el hecho físico; Index nunca decide si un snapshot existe.

El invariante que decide qué entra

Index guarda lo que el catálogo no puede saber. Lo que el catálogo sabe, se le pregunta — no se copia.

Dos corolarios, y son duros:

  1. Todo hecho derivable del catálogo que viva en Index es andamio, por muy portante que sea hoy. No importa que funcione: importa que obliga a cada escritor nuevo a acordarse de reportarlo, y por eso cada motor nuevo cuesta una migración.
  2. Todo lo que el catálogo no puede saber es de Index, y se queda. No es deuda pendiente de migrar. Unity Catalog es una base de datos; el nuestro también debe serlo.

Por qué el catálogo no puede saberlo: Lakekeeper ofrece pares clave-valor por tabla y por commit — el slot existe, y de hecho ya lo usamos para carbon.operation-id. Lo que no existe es poder consultarlo relacionalmente, recorrer el grafo de linaje, o cruzarlo con usuarios, proyectos y permisos. La carencia no es de almacenamiento: es de consulta.

La regla de nombrado (obligatoria)

«Index» es un nombre tomado en este stack, en cuatro capas. Se adoptó igual porque el término que sustituye —«control-plane»— estaba peor: nombraba a la vez el pilar que se queda y withNativeControlPlane, la función que estampa las copias que hay que retirar. Una palabra para las dos mitades de la distinción impide enunciar la tesis.

ColisiónRegla
__row_index (838 usos)Index con mayúscula y artículo («el Index») = el pilar. __row_index siempre con sus dos guiones bajos
índices de Postgres (342 CREATE INDEX)«índice» en minúscula siempre cualificado: «el índice datasets_coordinate_unique»
el índice de Karma (zonemap/bloom/Puffin)siempre «el índice de Karma»
621 ficheros index.tsIndex nunca es nombre de fichero ni de directorio

2 · La naturaleza de la separación

No se levanta moviendo columnas. Se levanta partiendo el camino de control del camino de datos, y poniendo Index en el primero —donde es barato, raro y autoritativo— y fuera del segundo, donde sería un cuello de botella.

 ┌─ CAMINO DE CONTROL ── raro, caro por operación, DEBE ser autoritativo ────────┐
 │  crear objeto · resolver coordenada → ubicación + credencial · DDL · política  │
 │                            ▼  pasa POR Index                                   │
 └────────────────────────────────────────────────────────────────────────────────┘
                              │ devuelve: ubicación opaca + credencial acotada
 ┌─ CAMINO DE DATOS ── caliente, masivo, Index NO puede estar aquí ──────────────┐
 │  el motor lee/escribe Parquet en R2 y commitea el CAS contra Lakekeeper        │
 └────────────────────────────────────────────────────────────────────────────────┘
                              │ el catálogo cuenta lo que pasó
 ┌─ CAMINO DE HECHOS ── masivo, asíncrono, NADIE tiene que acordarse ────────────┐
 │  Index PROYECTA: snapshot, filas, esquema, linaje, veredicto de calidad        │
 └────────────────────────────────────────────────────────────────────────────────┘

La regla que lo resume: Index decide antes; el catálogo ejecuta y cuenta; Index proyecta después. Ningún escritor reporta nunca.

El movimiento clave: romper el espejo de nombres

Hasta esta sesión el namespace físico se derivaba de la taxonomía: {catalog.slug}.{schema.slug}. El nombre donde viven los bytes espejaba el nombre que el usuario puede renombrar. De ese espejo salían tres problemas que se trataban por separado como si no fueran el mismo:

  1. la fórmula tenía que existir en dos lenguajes y mantenerse a mano — y ya se rompió una vez (F3);
  2. renombrar un schema movería datos, así que en la práctica no se podía — de ahí el pin defensivo;
  3. dos sistemas afirmaban ser la autoridad del namespace.

El principio que lo resuelve: el namespace físico debe espejar lo INMUTABLE (el tenant), no lo EDITABLE (la taxonomía).

Forma adoptada: {env}.w_{workspace}. env porque dev y prod se aíslan por namespace y romper el espejo lo habría borrado. w_<workspace> porque el tenant no cambia nunca — y cobra un bonus real: OpenFGA hereda jerárquicamente sobre namespaces, así que un namespace por workspace hace la tenencia aplicable en el catálogo, que es lo que hará falta cuando Index se ponga delante.

Los cinco invariantes

  1. Ninguna ubicación física se deriva. Sólo hay un resolvedor, y lee lo guardado.
  2. Ningún objeto existe sin coordenada gobernada.
  3. Ningún escritor reporta hechos. Se proyectan.
  4. El CAS sigue siendo de Lakekeeper. Index nunca serializa escrituras: sería un segundo punto de linearización y una fuente de split-brain.
  5. La superficie de Index es una SPEC ABIERTA, nunca una API propietaria. Index puede ser el punto único porque habla Iceberg REST; el día que hable sólo lo suyo, es lock-in con otro nombre.

3 · El recorrido de esta sesión

3.1 · Cartografía (docs)

7dd23b0Index nombrado. warehouse-index.md (el pilar 3: qué contiene en tres anillos, qué no puede contener nunca, y su estado real medido) + warehouse-scaffolding-retirement.md revisión 2, re-medido por 26 agentes contra el código
e384694index-frontier.md — el diseño de cómo se levanta: control ⊥ datos ⊥ hechos
33b980cindex-n0-n2-approach.md — el approach del primer tramo, con call-sites exactos

3.2 · Seguridad del SQL Editor (alcanzable sin canary)

8fa079cH4/columns interpolaba el FQN crudo en un f-string ejecutable. Arreglado partiendo y re-citando el identificador (no una lista negra) + el catálogo anclado al adjunto. Encontró de paso el mismo agujero en ensure_namespace
989efdbH5 — la puerta era ciega tras una coma: FROM t, read_parquet('s3://…') no entraba en baseTables. scanTableRefs + lista blanca de funciones de tabla. Efecto colateral: el comma-join entre dos tablas del Warehouse estaba roto y ahora funciona
e241e86H6 (pérdida silenciosa y permanente de filas en la ventana delta) + D·2 (verbos restringidos en la puerta a DELETE/MERGE)

3.3 · Index: las fugas y la ejecución

3a0a7bcI·1 — las dos fugas del carrier de procedencia. El linaje se escribía y no se leía
7ff8903 1646bbaN·-1 — los ratchets cableados a CI. La guarda en la que se apoyaba todo el plan no la disparaba nadie
7dd8035N·0 — el namespace se RESUELVE, deja de asumirse. 8 sitios migrados, suelos 11 → 4
0ac5a17 7b4818cN·1 — el espejo roto, con gate 6/6 verde en vivo: se renombró un schema y la lectura siguió funcionando
a167b10N·2a — CloudEvents medido: hay emisor, no hay receptor
5e56d9d 89086f9N·2b — la primitiva de PULL (/lakehouse/table-status) y la sombra
15a1657N·2c — row_count deja de ser COPIA y pasa a ser PROYECCIÓN
1d7f602N·2c-bis — el count(*) por DML sustituido por metadata
c4a21cbGate de W·2 — el censo
6bf0666 7ea3bd1El control-plane del DML deja de mentir + la atribución, medida

3.4 · Sesión 2 (2026-07-30) — validar las prioridades contra el runtime

No se construyó nada nuevo del plan: se midió lo que el plan daba por sabido, y dos de esas medidas lo reordenan (§5·8-10 · §7).

El censo del carrierscripts/dataspaces/i3-carrier-census.ts — sólo lectura, re-corrible. Es lo que convierte «I·1 desbloquea el linaje» en un número: 0 de 101
I·3a — el lector mira las dos columnasresolveCarrier en junction.ts + 5 tests. La tercera fuga del carrier, y era de COLUMNA, no de forma ni de nombre
Re-medición en vivosombra (15 nativos), reconciliador en dry-run (15 match · 0 skipped · 0 unknown), table-status crudo de animal_events, y el ledger del DML

4 · Estado — qué está hecho, qué está inerte

PiezaEstado
Index nombrado y delimitadowarehouse-index.md
N·-1 ratchets en CI✅ verde · job guards bloqueante, full-suite informativo
N·0 un solo resolvedor✅ desplegado · ratchet check:namespace-resolution
N·1 espejo roto✅ código + gate en vivo · INERTE (faltan LAKEHOUSE_ENV y LAKEHOUSE_OPAQUE_NS_WORKSPACES)
N·2b primitiva de pull✅ desplegada en ml-runner
N·2c reconciliador✅ código + scheduler · INERTE (falta ENABLE_LAKEHOUSE_RECONCILE)
H4 · H5 · H6 · D·2 · H7✅ cerrados
I·3a el lector mira las DOS columnas del carrierresolveCarrier en junction.ts · 5 tests · inerte para los 101 satelitados
W·2 retirar el oráculo⛔ gate medido: toca camino vivo, tres compuertas con canario
Compactación🔒 re-encuadrada: la primitiva que se daba por hecha no existe (§5·8) y la incidencia hoy es cero (§5·9)

Lo que está INERTE y cómo se enciende:

# N·1 · namespace opaco (por workspace; el gate ya está verde)
LAKEHOUSE_ENV=test
LAKEHOUSE_OPAQUE_NS_WORKSPACES=<workspaceId>

# N·2c · reconciliador de row_count (arranca en dry-run)
ENABLE_LAKEHOUSE_RECONCILE=true
LAKEHOUSE_RECONCILE_APPLY=true   # sólo cuando la observación convenza

# D·2-bis · deny-by-default del destino — servicio duck-server
DUCK_REQUIRE_WRITE_TARGET=true

5 · ⭐ Las premisas que resultaron FALSAS al medirlas

Esta es la sección que ahorra iteraciones. Cada una venía de un documento nuestro y sonaba razonable.

  1. «Los 8 lectores STRICT están en defaultMode:'off', el oráculo les es irrelevante.»FALSO. Hay 19 filas en lakehouse_read_flags y cinco de los ocho están flipeados a iceberg con scope GLOBAL. W·2 toca camino vivo: 75 pares servidos por Iceberg, 45 expuestos a caer a PG vacío.
  2. «Derivar row_count elimina un reporte obligatorio por escritor» (los ~15 a retirar).FALSO. Los cuatro caminos nativos relevan el total-records del snapshot o lo verifican con una aserción que lanza. No es una copia que pueda driftar. Lo que importa no es cuántos escritores estampan, sino de dónde sacan el número.
  3. «row_count, column_count y size_bytes salen juntos con W·1.»FALSO para el tercero. La sombra midió 0/15 de deriva en los dos primeros y 15/15 en size_bytes — porque no es el mismo número: writer.py:245 es arrow_table.nbytes, # in-memory size (indicative), frente a bytes de Parquet en disco. Derivarlo cambiaría su significado.
  4. «El catálogo ya nos cuenta lo que pasa, exactly-once» (CloudEvents).Cierto como capacidad del producto, FALSO como capacidad disponible. Los únicos sinks son NATS y Kafka; no corremos ninguno. Hay emisor, no receptor.
  5. «La guarda barata de H6 es excluir las txns nativas de la ventana.»FALSO, y por poco se aplica. Una ventana vacía también pasa el gate. Hay que descalificarla.
  6. «duck-server puede estampar snapshot_propertiesFALSO. La extensión sólo tiene propiedades de tabla y schema. No re-investigar.
  7. «Nadie configura el layout ⇒ ninguna propiedad honrada» (H8).FALSO la mitad. DuckDB trae ignore_{target_file_size,row_group_size}_for_partitioned_tables, y un setting para ignorar algo sólo existe si por defecto se honra. El layout viaja en la metadata de la tabla, no en la sesión.
  8. «Un replace YA es una compactación ⇒ el compactador es un scheduler sobre una primitiva que ya corre.»FALSO. El replace publica delete-all + add_files en una transacción, sí — pero los paths salen de un stream de filas EXTERNO (run_lakehouse_ingest(rows: AsyncIterator) / un Parquet ya materializado). No existe ningún camino que lea la tabla Iceberg y la reescriba. Repetir el replace desde el ORIGEN sobre una tabla que ha recibido DML borraría el DML: eso no es compactar, es perder datos. Falta una primitiva self-replace — y además el replace re-acuña __row_id (uuid4 por fila), que la ontología usa como puntero de procedencia.
  9. «Sin compactación la derivación se degrada» ⇒ la compactación es Prioridad 1.El mecanismo es cierto; la incidencia es CERO. Medido en vivo: 0 de 15 tablas nativas llevan delete files, incluida animal_events — el único dataset con DML real (4 DELETE + 3 UPDATE commiteados el 2026-07-29): total-delete-files="0", total-records=4, y el count(*) de DuckDB también 4. El reconciliador no se salta ninguna tabla hoy, y la guarda que lo haría está verificada, no supuesta.
  10. «Las filas ya escritas en producción llevan el carrier roto: había que recuperarlas» (I·1).FALSO, y por el motivo contrario al que parecería. El censo (i3-carrier-census.ts): 0 de 101 datasets llevan procedencia, en ninguna de las dos formas. No hay nada que recuperar. La causa no es que el productor escriba mal — es que no ha corrido nunca: Fase D aterrizó el 2026-07-03 (965d6e7) y el derivado más reciente de producción es del 2026-06-18. I·1 es correcto y protege escrituras futuras; su valor medido hoy es cero filas.

Y la lección de proceso, que se repitió TRES veces

El repo no es el runtime. El default del código no es el modo de producción.

.env.local apuntaba a SqlCatalog + Supabase Storage (config local rancia, con pegados a los valores) · el namespace de prod ya estaba corregido a main.default cuando INFRA.md lo daba por pendiente · y el defaultMode del registry está sobreescrito globalmente. Medir antes de razonar, y medir contra /lakehouse/config del servicio vivo.


6 · Hallazgos que no buscábamos

  • 🔴 DuckDB 1.5.4 ya trae unsafe_iceberg_ignore_sort_order, que permite INSERT/UPDATE sobre tablas con sort order. La «protección accidental» de D·0 no cae el día que lo implementen: ya está, es un flag. D·2 no era un seguro para el futuro — era load-bearing cuando se hizo.
  • 🔴 El único workflow del repo llevaba rojo en cada push, y no por los tests: No space left on device instalando el stack ML. Los tests de ml-runner gateados por pyiceberg no se ejecutan en CI. Sin arreglar.
  • duck-server no tenía CI ninguno pese a servir el tráfico del SQL Editor. Ahora corre sus 100 tests.
  • datasets.project_id es NOT NULL en la BD vivaproject & files es portante, confirmado empíricamente (lo que declaraba 20261249).
  • La compactación no necesita primitiva nueva: un replace sobre una tabla nativa ya es una compactación — reescribe los data files, colapsa los delete files y re-estampa __row_index. El compactador es un scheduler sobre una primitiva que ya corre.

7 · Cómo continuar

⚠️ Este orden se REORDENÓ el 2026-07-30 con medición, no con argumento. La compactación era la Prioridad 1; baja porque su incidencia es cero y su primitiva no existe (§5·8-9). El hueco lo ocupa el carrier de procedencia, que resultó estar vacío en producción (§5·10) — y eso abre una ventana que se cierra sola.

⭐ Prioridad 1 · Mover el carrier de procedencia AHORA, que no cuesta backfill

⚙️ El approach con los call-sites exactos y las fases está en index-i2-i3-approach.md. Dos cosas que salieron de ese reconocimiento y cambian el tamaño del trabajo: (a) «194 puntos de acceso» es el número equivocado — la puerta intercepta el nacimiento, y son 21, de los que 7 ya pasan por el chokepoint: el trabajo real son 14; (b) los cinco cuerpos duplicados del chokepoint ya han driftado, y el drift es un defecto vivo: las tres derivaciones de transformPaths no propagan el tier, así que un transform/join/split sobre una tabla gold_ produce un output sin tier.

El censo dice que ninguno de los 101 datasets satelitados lleva procedencia, y que ningún derivado ha nacido desde que existe la propagación. Consecuencia: trasladar la procedencia de project_files.metadata a datasets.metadata no tiene que migrar ni una fila — sólo hay que apuntar a los productores. Cada derivación que nazca a partir de hoy encarece ese movimiento.

El lector ya está listo (I·3a hecho: resolveCarrier lee las dos columnas, canónico gana). Lo que falta es el lado productor, y va junto con I·2 — porque «quién escribe la gobernanza» y «dónde la escribe» son la misma pregunta, y hoy hay 194 puntos de acceso a datasets y ninguna puerta.

⚠️ Precisión que hay que conservar: lo que no necesita backfill es la procedencia. project_files.metadata sí lleva config viva (media sets, formatos, transaction_policy) en las 101 filas — eso no se mueve con esto.

Prioridad 2 · Encender lo que está inerte

Sin cambios, y ahora con la línea base re-medida hoy en dry-run: 15 escaneados · 15 match · 0 drifted · 0 skipped · 0 unknown — ni una tabla inalcanzable, ni una saltada. Su cortacircuitos de merge-on-read está medido efectivototal-delete-files viene poblado y correcto en los snapshots que escribe DuckDB, no ausente (que era el modo de fallo silencioso que habría hecho que la guarda no viera nada). Una semana en observación da la línea base con datos reales. DUCK_REQUIRE_WRITE_TARGET=true sigue siendo un flip.

Prioridad 3 · W·2, con el orden que dicta el censo

Al revés que en el plan: primero los 3 puertos aún en off (donde retirar la compuerta arregla algo), y sólo después el oráculo para los 5 que ya sirven Iceberg (donde tocar puede romper algo). Antes conviene decidir si los 45 pares expuestos son incidente o estado esperado — se acota midiendo uso real en vez del producto cartesiano.

La compactación — cuándo vuelve a la mesa

No antes de que haya delete files que compactar. El disparador es medible y ya está instrumentado: n2b-shadow reporta «tablas con delete files». Cuando ese contador deje de ser 0, sube — y entra con el diseño corregido: una primitiva self-replace (leer lo visible → reescribir), no una re-ingesta desde el origen, que borraría el DML. Ojo con __row_id: el replace lo re-acuña (uuid4 por fila salvo en upsert con PK).

Después

I·4 (aplicar o degradar las 7 piezas declaradas-y-no-aplicadas) · I·5 (consolidar los 5 almacenes de linaje) · N·3 (la cara Iceberg REST de Index).


8 · Decisiones abiertas del owner

  1. ¿Quién autoriza, Index u OpenFGA? No bloquea lo hecho; sí bloquea cobrar el bonus de tenencia del namespace por workspace. Dos fuentes para la misma regla es peor que cualquiera de las dos.
  2. ¿Los 45 pares expuestos de W·2 son incidente o estado esperado? Si pipeline.source sobre un nativo se usa de verdad, hay pérdida silenciosa hoy y reordena la prioridad.
  3. ¿datasets.schema es contrato o copia? El dudoso que más pesa: lleva isPrimaryKey, que el catálogo no sabe. No es derivación limpia, es una separación pendiente.
  4. ¿Se sustituye size_bytes por total-files-size? Es un cambio de semántica, no una derivación. Probablemente a mejor, pero hay que auditar consumidores.

9 · Convenciones (no tropezar)

  • Responder al owner en ESPAÑOL.
  • Nunca git add -A — por rutas explícitas. El owner tiene WIP sin trackear (components/workspace/tabs/bi/, lib/icons/bi.ts, WorkspaceShell.tsx).
  • Harness-first: cada fase tiene el suyo en scripts/. n1-opaque-namespace-gate, n2b-shadow, reconcile-row-counts, w2-strict-census, check-namespace-resolution, y de la sesión 2 i3-carrier-census (el censo del carrier de procedencia — sólo lectura).
  • Validar Python con ast.parse antes de commitear en services/.
  • tsc grandeNODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit.
  • 18 fallos de vitest son PRE-EXISTENTES (medidos stasheando). El job full-suite los mantiene visibles sin bloquear.
  • .env.local tiene 3 PLACEHOLDERS: credencial OIDC del catálogo y las dos llaves R2 — que además conviene rotar (estuvieron en chat).
  • Una guarda que no se ha visto fallar no es una guarda. El ratchet de namespace se verificó inyectando una regresión; el gate de N·1 tenía un || true que lo hacía pasar siempre.

Ancla: warehouse-index.md (qué es Index) · index-frontier.md (el diseño) · index-n0-n2-approach.md (la ejecución de N·-1→N·2) · index-i2-i3-approach.md (la iteración SIGUIENTE — Prioridad 1) · warehouse-scaffolding-retirement.md (lo que sale) · duckdb-conformance.md (el SQL Editor) · junction-handoff.md (la puerta) · INFRA.md (la topología real). Memoria: warehouse-index-pillar, router-junction.