Published

Retirar el andamio: qué se deriva, qué se retira y qué se queda

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...

Retirar el andamio: qué se deriva, qué se retira y qué se queda

Entregable (2026-07-29, revisión 2 — verificado contra el código). Carbon fue un producto Postgres antes de ser un lakehouse. Iceberg llegó como espejo, y Postgres nunca dejó de ser la fuente de verdad. Hoy el dato ya vive y es consistente en Iceberg + R2 + Lakekeeper, pero seguimos arrastrando los términos de la pasarela.

La tesis: el andamio no es «Postgres». Son, muy concretamente, los hechos que copiamos del catálogo a Postgres en vez de derivarlos de él. Lo demás que hay en Postgres —el modelo de objetos y la gobernanza— no es deuda: es el tercer pilar del Warehouse, hoy nombrado Index (warehouse-index.md).

La tesis se sostiene: 26 agentes la contrastaron línea a línea contra el código y ninguna cita resultó falsa. Lo que cambia en esta revisión es la magnitud y el orden de casi todos los pasos, y en dos sitios el código la respalda más fuerte de lo que el propio documento decía.

Sustituye el encuadre de duckdb-conformance.md, que atacaba el mismo problema por el lado equivocado: cómo hacer que un motor encaje en un contrato invisible, en vez de reducir el contrato.


1 · Por qué existe el andamio

Míralo en las columnas de la tabla que estamos retirando. dataset_rows lleva al menos (id, dataset_id, transaction_id, row_index, data, created_at) — las cuatro del medio son las que escribe el grueso de los productores, con clave de conflicto (dataset_id, row_index). (Su CREATE TABLE no existe en el repo: precede al tracker de migraciones. El columnario exacto sólo se cierra con un information_schema contra la BD viva.)

Columna de dataset_rowsQué fue de ella al llegar Iceberg
row_index__row_index (sync_service.py:42)
id__row_id, sólo en el espejo. En un dataset nativo __row_id es un uuid acuñado por el servidor, no herencia de PG (writer.py:255-256)
transaction_idNo viaja a Iceberg. El id del ledger sí llega, pero como propiedad de snapshot (carbon.operation-id), no como columna — y sólo en el write nativo

Las columnas de identidad son la clave primaria y el cursor de la tabla Postgres original, llevados a Iceberg. No son diseño de lakehouse: son herencia. Existen para que una tabla Iceberg se comporte como la tabla Postgres a la que sustituye — que era exactamente lo que hacía falta durante la pasarela.

De ahí sale todo lo demás. El oráculo de frescura existe para decidir si un lector va a PG o a Iceberg mientras ambos tienen dato. El row_count en datasets existe porque la UI lo leía de Postgres. El ledger es verdad sobre snapshots porque cuando se escribió no había catálogo al que preguntar.

⚠️ transaction_id no apunta a un ledger. Es una referencia blanda —la FK se dropeó en 20260633, que lo documenta: «Polymorphic FKs aren't a Postgres feature»— a tres registros distintos: dataset_transactions (polling), sdk_dataset_transactions (SDK) y pipeline_build_transactions (builds), unidos por la vista dataset_transactions_all. Partir «el ledger» (§5·W·3) es partir tres tablas con capacidades distintas, no una.


2 · La distinción que decide la migración

El andamio son hechos que el catálogo ya sabe y nosotros copiamos. Cada copia obliga a que cada escritor se acuerde de reportar, y es exactamente lo que hace que cada motor nuevo cueste una migración.

Index (el tercer pilar) es lo que el catálogo no puede saber: coordenada, tier, linaje, contraparte, permisos, ontología. Su inventario completo y su estado real están en warehouse-index.md; aquí sólo importa la frontera.

La diferencia real con Databricks no es cuántas capas hay, sino dónde están. Las suyas están en el camino. Las nuestras están al lado: el motor commitea en Iceberg y después alguien tiene que acordarse de actualizar Postgres. Eso explica ratify: existe porque el control-plane está al lado. Es un detector de «¿quién se olvidó?».

La prueba viva de que «al lado» ya no escala está en el brazo DuckDB de la puerta. En junction.ts:480-540, después de la frontera irreversible (el motor ya commiteó en el catálogo), el código todavía hace tres escrituras de reporte a Postgres — ledger, iceberg_sync_log, stats de datasets — dos de las cuales lanzan si fallan. Y la copia ya está mintiendo en dos sitios, con aviso en el propio código: iceberg_snapshot_id: null («el snapshot lo posee el catálogo y el motor no lo devuelve») y has_identity: true marcado «⚠️ LITERAL, no verificado».

El argumento no es de simplicidad. Es de correctitud: con un motor que no es pyiceberg, la copia ya no se puede mantener.


3 · El inventario

3.1 · DERIVAR — el catálogo ya lo sabe

datasets.row_count. Lo estampan 28 sitios vivos: 23 en TypeScript y 5 funciones RPC dentro de Postgres. Ningún escritor Python lo estampa — ml-runner y duck-server sólo reportan un número por el cable y es Node quien lo persiste. No hay trigger ni columna GENERATED que lo respalde: si un escritor se olvida, nadie lo corrige.

Cuatro precisiones que cambian el tamaño del trabajo:

  1. Sólo ~15 de los 28 escriben sobre datasets respaldados por Iceberg. Los otros ~13 escriben row_count donde no hay snapshot del que derivar: manifiestos file-backed en Supabase Storage, lotes de fichero del SDK (donde row_count es nº de ficheros, no de filas), el overlay de branch y las salidas PG de pipeline. La derivación no es total: es una segunda semántica conviviendo con la primera.
  2. Para el path que importa es UNA línea. Todo el brazo nativo copia el número en un solo sitio: iceberg-native-write.ts:319. Los otros 27 o no tienen Iceberg detrás, o son el DML de F4b (junction.ts:535).
  3. La derivación ya está construida y en producción — apuntando a la columna equivocada. ratify.ts:142 lee status.totalRecords (que viene del summary del snapshot vía getOperationStatus) y lo escribe en dataset_transactions.row_count, nunca en datasets.row_count. W·1 no es «construir la derivación»: es reapuntarla.
  4. El mismo número está triplicado: datasets.row_count + dataset_transactions.row_count + iceberg_sync_log.rows. Igual con size_bytes.

Matiz que hay que resolver antes: total-records no es exacto bajo merge-on-read. El propio código lo advierte (verify_service.py:27-30: «delete files can make total-records drift from the visible row count»), y un DELETE/UPDATE de DuckDB sobre Iceberg es exactamente eso. El count(*) que F4b hace hoy no desaparece: se convierte en el fallback obligatorio.

El ledger como verdad sobre snapshots. dataset_transactions tiene dos trabajos conflados, y sólo uno es andamio:

  • «Qué le pasó a la tabla»status, committed_at, row_count, size_bytes. Andamio.
  • «Qué íbamos a hacer, apuntado antes de tocar el dato»se queda, y es imprescindible. Es el invariante 2 de JOP, y de ahí sale el operation_id que viaja dentro del commit.

⚠️ La partición no es 50/50, y dos columnas están en las dos mitades a la vez. status y committed_at son también el índice de la ventana incremental ((low, high]). W·3 no puede quitarlas; como mucho puede cambiar quién las escribe.

3.2 · RETIRAR — es de la pasarela y ya no aplica

El oráculo de frescura, para datasets nativos. 20261225_lakehouse_read_router.sql:186-192: caught_up es un EXISTS que comprueba que haya una fila de iceberg_sync_log con status='succeeded' cuyo pg_transaction_id sea la última txn commiteada. Es decir: compara dos tablas de Postgres. Para un dataset nativo, dataset_rows está vacío — el oráculo pregunta si Iceberg va al día respecto de la nada, y la caída a PG que protege es la que rompe.

⚠️ Pero el oráculo es el SEGUNDO gate, no el primero. read-router.ts:126 sólo lo consulta if (mode !== 'off'), y los 8 lectores STRICT están declarados con defaultMode: 'off'. Para ellos, hoy, caught_up es irrelevante: caen a PG siempre. Retirar el oráculo, solo, no cambia nada observable — W·2 tiene que mover también el default del puerto.

Y hay un tercer gate independiente: icebergIdentityReady (freshness.ts:91-108) lee iceberg_sync_log.has_identity y también hace caer a PG. Retirar iceberg_sync_log es tres cortes, no uno.

El área de daño es mayor de lo que parece. No son sólo lecturas de filas: la hidratación source-respecting está condicionada al mismo gate (sql-source-hydration.ts:87-89, source-resolver.ts:74-76), así que el SQL del usuario, los dashboards, las expectations y el input de los pipelines compilarían sobre una tabla vacía sin error.

dataset_rows. Ya en retirada, y la retirada es real: la allowlist encoge (dataset-output-materialiser causó baja). Pero hay tres correcciones al «no hay nada nuevo que decidir aquí»:

  • ⚠️ El ratchet NO corre en CI. El único workflow del repo es ml-runner-tests.yml, filtrado a services/ml-runner/**. No hay .husky, y package.json no encadena check:dataset-rows-seam a build/test/lint. Es un comando manual. (source-of-truth.md:153 lo llama «CI ratchet» — eso es falso hoy y hay que corregirlo.)
  • ⚠️ Seis migraciones escriben dataset_rows desde DENTRO de Postgres (los RPC del SDK y de branches). El ratchet sólo escanea .ts bajo app/, lib/ y services/: no ve nada de eso, y ese path no se puede «rutar por el seam» porque el seam es TypeScript.
  • El perímetro tampoco cubre scripts/ (16 accesos crudos vivos, incluido el propio canary de pipeline.output) ni .tsx.

Superficie real: 20 ficheros de producción escriben y ≥28 leen. En 11 de ellos la lectura y la escritura viven en el mismo WITH … INSERT: no se pueden migrar por separado.

3.3 · MOVER AL FORMATO — deja de ser contrato nuestro

__row_id → row lineage de Iceberg v3. Tiene consumidor real y verificado (objects/route.ts:283, objects/sync.ts:116dataset_row_id).

⚠️ Pero la propiedad que queríamos preservar hoy no existe en general. __row_id es estable sólo en dos casos: datasets espejo (donde es dataset_rows.id) y nativos con write_mode='upsert' + primary key (uuid5). En replace/append sin clave se re-acuña un uuid4 por fila en cada snapshot (ingest_service.py:317-319). Para el caso mayoritario, el enlace objeto↔fila ya se rompe en cada re-snapshot.

Eso cambia el caso de negocio de W·4 —de «preservar lo que hay» a «arreglar algo que está roto»— y lo mejora. Y baja el listón: la ontología ya no lee la fila por ese id (20261228 denormalizó base_properties y quitó el JOIN); lo conserva como puntero de procedencia + cascada de borrado. Basta un identificador de procedencia, no una clave de acceso — lo que abre una tercera vía: que viva en el linaje, no en una columna de datos.

__created_at — la retirada más barata, y no está en el plan. Es la tercera columna de identidad obligatoria (writer.py:263-264), se transporta hasta la respuesta del browse, y ningún consumidor la lee: el resto del sistema la borra activamente en cinco sitios. No depende del gate v3 ni de la pregunta de la paginación. Debería ser W·0.5.

__row_index — la pregunta incómoda. Es el cursor de keyset, y la lista de consumidores del documento anterior se quedaba corta: son once, incluidos la edición/borrado de fila individual, el graph explorer, el motor Kuzu (donde se convierte en PRIMARY KEY), el export, el SDK NDJSON, la primitiva misma de paginación y toda la UI.

⚠️ Y el fan-out no es un consumidor: es un ASIGNADOR. Reserva rangos disjuntos de 2**40 por partición, con una columna row_index_base bigint NOT NULL en una tabla de control (20260643). __row_index no obliga a cada motor a estampar una columna: obliga a un protocolo distribuido de reserva de rangos. Es la evidencia más fuerte a favor de la tesis de este documento, y estaba mal encuadrada.

No tiene equivalente en el formato, y por un motivo: no es identidad, es paginación Postgres emulada sobre un object store. La pregunta correcta no es «cómo lo mantenemos en todos los motores» sino «por qué paginamos por índice». No se resuelve aquí (§6·3).

3.4 · QUEDARSE — es Index

El modelo de objetos, la gobernanza, el linaje, la ontología y los permisos. Más la mitad del ledger que registra intención. Inventario, estado real y plan: warehouse-index.md.

⚠️ Dos correcciones al documento anterior: datasets.project_id/file_id no son legacy (20261249 revocó esa etiqueta: «No hay D5 para estas columnas»), y el linaje no es una pieza sólida — son cinco almacenes desconectados, y el carrier de procedencia tiene dos fugas medidas.


4 · GATE W·0 — la promoción a Iceberg v3

El bloqueo no está donde parecía, y esta revisión invierte también la revisión anterior.

Lo verificado

⛔ pyiceberg 0.11.1 no puede serializar metadata v3 en absoluto. TableMetadataV3.model_dump_jsonraise NotImplementedError("Writing V3 is not yet supported", apache/iceberg-python#1551). Es un bloqueante más profundo que el SUPPORTED_TABLE_FORMAT_VERSION = 2 que citábamos: ese cap sólo gobierna la promoción in-place. Subir el cap sin esto no habilita nada.

Pero en el path de PROD, pyiceberg no acuña el metadata. El catálogo es REST: catalog/rest/__init__.py:775-816 reenvía properties tal cual al servidor dentro del CreateTableRequest; new_table_metadata no se invoca. Quien decide la format-version de nuestras tablas es Lakekeeper. Y pyiceberg 0.11.1 sí sabe parsear v3.

Consecuencia: la pregunta (3) —«¿acepta Lakekeeper v3?»— pasa de tercera a PRIMERA, y es posible que crear+leer v3 ya funcione hoy pasando properties={'format-version':'3'}, con lo único roto siendo el path SqlCatalog (dormante).

No sabemos en qué format-version estamos. Nada en el repo la mide. Y el propio repo lo declara pendiente en dos sitios (f4-governed-dml-approach.md:117, f4-cross-engine-delete-harness.ts:135). Medirlo es trivial: leer el format-version del metadata.json de una tabla de main.test.

Nada está pineado. requirements.txt:88 es un rango (>=0.8,<1.0) sin lockfile: la imagen resuelve la versión en cada build — un rebuild podría ya haberla subido sin que nadie lo decida. La extensión Iceberg de DuckDB se instala por nombre contra el repositorio de extensiones (medido en local para 1.5.4: build 75726455). Y la versión de Lakekeeper sólo existe como prosa en INFRA.md:37.

No hay un único punto de creación. Son tres: _create_native_table (el chokepoint declarado), un catalog.create_table desnudo en write_overwrite:217 (código muerto, sin llamadores — candidato a borrarse antes del gate), y el CTAS de DuckDB por el write-path gobernado. Con dos motores creando tablas, la format-version es una política del catálogo, no una constante del writer — lo que refuerza la tesis de §7.

Las cuatro preguntas, reordenadas

  1. ¿Acepta Lakekeeper metadata v3? (ahora la primera: es quien acuña)
  2. ¿Sabe la extensión Iceberg de DuckDB leer y escribir v3? Sin esto, promover rompería el motor que hoy sirve tráfico. Y no tiene respuesta estable mientras la extensión flote: el pin pasa a ser parte del gate.
  3. ¿Existe alguna versión de pyiceberg <1.0 que implemente la escritura de v3? Ya no es «una consulta al changelog»: es esperar a que se cierre un issue upstream.
  4. ¿Es la promoción in-place? Confirmar sobre una tabla de main.test.

Regla del gate: el paso 0 es leer GET /lakehouse/config.pyiceberg_version — no asumir 0.11.1. La decisión (2) del owner no es «subir una dependencia»: es pinearla por primera vez. Hoy el writer de toda la plataforma flota.


5 · Plan

  • W·0 · GATE v3 (§4). Las cuatro preguntas, en el orden nuevo, empezando por el probe de versión.
  • W·0.5 · Retirar __created_at. Tres columnas de identidad obligatorias, una sin ningún consumidor. No depende de nada. (Nuevo — el paso más barato del plan.)
  • W·1 · Derivar row_count. Reapuntar la derivación que ya existe (ratify.ts:142) de dataset_transactions a datasets. Coste real que el plan anterior no contaba: (a) hace falta un endpoint que hoy no existe — «dame el total-records del snapshot actual de la tabla X»; lo más cercano (/lakehouse/operation-status) exige un operation_id que haya aterrizado; (b) el gate debe declarar qué snapshot se lee y con qué orden — el orden del array que sirve Lakekeeper no es fiable y ya produjo un bug de «cero filas» esta semana; (c) assertPublishIsNotDestructive (sync-worker.ts:99-116) funciona precisamente porque row_count es una copia independiente: derivarlo lo vuelve tautológico. Hay que reescribir ese guard antes, o retirarlo con W·2.

GATE DE W·2 MEDIDO (2026-07-30, c4a21cb) — y contradice lo de abajo. El análisis decía que los 8 lectores STRICT están en defaultMode:'off', así que el oráculo les sería irrelevante y «con los defaults del repo el cardinal sería 0 sin overrides». Falso en producción: hay 19 filas en lakehouse_read_flags y CINCO de los ocho STRICT están flipeados a mode=iceberg con scope GLOBAL (datasets.browse, datasets.rows.lookup, graph.explorer.{data,search,values}).

Censo: 120 pares (15 nativos × 8 STRICT) → 75 servidos por Iceberg · 45 expuestos a caer a PG vacío. Los 45 son los 3 puertos que siguen en off (pipeline.source, model.objects.grid, objects.links); para pipeline.source eso es un pipeline compilando sobre una tabla vacía sin error.

W·2 toca camino VIVO: las tres compuertas se retiran con canario, no de golpe. (Calibración: «expuesto» ≠ «roto» — el censo cruza todos los lectores con todos los datasets; cuántos pares se ejercitan de verdad depende del uso.) Harness: scripts/dataspaces/w2-strict-census.ts.

  • W·2 · Retirar el oráculo para datasets nativos. Son tres cortes, no uno: el EXISTS de caught_up, el defaultMode: 'off' de los 8 puertos STRICT, y el gate has_identity. Gate: censar cuántos datasets nativos sirven lecturas STRICT — el censo es componible con lo que ya hay (predicado metadata->>'sink'='iceberg_native' × READER_REGISTRY × lakehouse_read_flags × iceberg_freshness), y con los defaults del repo el cardinal sería 0 sin overrides.
  • W·3 · Partir el ledger. ⚠️ W·2 es su PREREQUISITO, no su paralelo: si status/committed_at pasan a derivarse por un ratify que corre cada 5 min, el oráculo vería caught_up=false durante toda la ventana de reconciliación y mandaría a los lectores STRICT a un Postgres vacío.
  • W·4 · __row_id al formato, si W·0 lo autoriza. Censar antes cuántos datasets con ontología son unkeyed (§3.3).
  • W·5 · La pregunta de la paginación (§6·3). No antes: es la única que puede cambiar el contrato de lectura.

Orden deliberado: W·0.5 y W·1 reducen superficie sin depender del gate. W·2 antes que W·3, por la dependencia de arriba.

⚠️ Y una guarda previa a todo: cablear el ratchet a CI (un workflow de 15 líneas). El plan entero se apoya en que la superficie no crezca mientras se deriva, y hoy esa guarda no la dispara nadie.


6 · Decisiones para el owner

  1. ¿Se acepta que Index se queda en Postgres, y para siempre? Recomendación: sí, y decirlo en voz alta. Ver warehouse-index.md §8·1.
  2. ¿Se promueve a v3? La decisión ya no es «subir pyiceberg»: es (a) preguntarle a Lakekeeper, que es quien acuña; (b) pinear pyiceberg y la extensión de DuckDB, que hoy flotan. Recomendación: medir primero (W·0), decidir después — y pinear en cualquier caso, independientemente de v3.
  3. ¿Se sigue paginando por índice? La decisión más grande. Ahora con un dato nuevo: __row_index no es una columna, es un protocolo distribuido de reserva de rangos. Añadirlo a un motor nuevo cuesta ese protocolo. Recomendación: línea de trabajo propia, con su propio entregable.

7 · Lo que este encuadre resuelve solo

Si row_count y la frescura se derivan del catálogo, una escritura de DuckDB deja de necesitar que DuckDB reporte nada. El problema de conformidad de duckdb-conformance.md se disuelve por sustracción.

⚠️ Con una condición que el encuadre anterior daba por gratis: derivar exige que el motor MARQUE su commit. ratify sale con unverifiable sin escribir estado si metadata.attributable !== true, y junction.ts:462-469 abre la txn de DuckDB con attributable: false porque el motor no puede marcar el snapshot. Lo mismo con upsert de pyiceberg.

Es decir: para el motor que motivó todo el encuadre, derivar es imposible hoy. El trabajo previo real no es partir el ledger — es que duck-server acepte y estampe snapshot_properties. Eso no invalida la tesis: la sustituye por un contrato mucho más barato y verificable. Cada escritor deja de reportar cinco hechos y pasa a estampar uno: su operation_id, que ya tiene.

Lo que no se disuelve, y sigue necesitando trabajo propio: la tenencia server-side de duck-server, el hueco de /columns, y la exfiltración por el read path. No son conformidad de formato: son seguridad, y viven en duckdb-conformance.md §3.


Cruza con: warehouse-index.md (el pilar que se queda) · junction.md (JOP y la puerta) · duckdb-conformance.md (el síntoma) · junction-write-spectrum.md · warehouse-as-narrow-waist.md.