Approach técnico · N·0 → N·2 — levantar la frontera, primer tramo
Documento de ejecución (2026-07-29). El diseño está en index-frontier.md. Esto es el cómo: los call-sites exactos, el orden, la guarda de cada fase y lo que mide.
El reconocimiento cambió el tamaño del trabajo en dos sitios, los dos a mejor. Están en §0 porque contradicen parcialmente al documento de diseño y hay que leerlos antes que nada.
0 · Lo que el reconocimiento corrigió del diseño
0.1 · El espejo está SÓLO en el namespace. El nombre de tabla ya es opaco.
El diseño dice «la fórmula del FQN desaparece, y con ella su test de conformidad». Es impreciso. La FQN tiene dos mitades y sólo una espeja algo:
| Mitad | Cómo se produce | ¿Espeja la taxonomía? |
|---|---|---|
nombre de tabla — ds_<uuid sin guiones> | datasetTableName() (TS) ≡ native_table_identifier() (writer.py:588-591) | NO. Deriva del id inmutable del dataset. Ya es opaco, ya es estable, y renombrar no lo toca. Se queda tal cual, y su test de conformidad también. |
namespace — {catalog.slug}.{schema.slug} | deriveTargetNamespace() (iceberg-native-write.ts:110-121) | SÍ. Es el espejo entero. |
Consecuencia: N·1 es mucho más pequeña de lo que el diseño sugiere. No hay que reescribir la identidad física — hay que cambiar una función de 12 líneas con un solo llamante, y sólo en el nacimiento de la tabla.
0.2 · El chokepoint de resolución ya existe. Lo que falta es que sea el único.
lib/lakehouse/dataset-namespace.ts ya es el resolvedor, y su cabecera documenta que el mismo error ha mordido tres veces (F3.2 · el cliente de atribución · el cliente de retención), con la regla ya escrita: «el namespace nunca se asume, se resuelve».
Pero sólo lo usan dos clientes. Los demás siguen asumiendo:
| Sitio | Qué hace | Veredicto |
|---|---|---|
retention-client.ts:20 · attribution-client.ts:24 | resolveDatasetNamespace() | ✅ resuelve |
compensate.ts:127 | copia inline del resolvedor | ⚠️ duplicado — borrar y reusar |
read-client.ts:90,228 · ingest-client.ts:66 · fanout-client.ts:103,129,172 · checksum-client.ts:50 | opts.namespace ?? defaultIcebergNamespace() | ❌ asume: si el llamante no lo pasa, cae al default global |
sync-worker.ts:28 · lakehouse-landing.ts:19 | const NAMESPACE = defaultIcebergNamespace() a nivel de módulo | ❌ asume, y además congelado al import |
junction.ts:1200 | datasetFqn('lake', entry.iceberg_namespace ?? null, entry.id) | ⚠️ resuelve del catálogo, pero con fallback al default |
Ocho sitios que asumen. Ese es el trabajo real de N·0, y es el que previene la clase de bug que ya nos costó tres incidentes — uno de ellos capaz de marcar aborted una transacción buena.
0.3 · Una restricción que el diseño no vio: hoy dev/prod se separan POR namespace
INFRA.md es explícito: una sola forma de runtime, y dev/prod se aíslan por namespace (main.test vs main.default) dentro del mismo warehouse. Si el namespace deja de derivarse de la taxonomía, esa separación desaparece si no se sustituye a propósito.
No es un obstáculo: es un requisito que la forma del namespace opaco tiene que respetar (§3).
Estado (act. 2026-07-29, noche)
| Fase | Estado |
|---|---|
| N·-1 ratchets en CI | ✅ hecho (7ff8903 + 1646bba) — .github/workflows/ci.yml, verde |
| N·0 un solo resolvedor | ✅ hecho (7dd8035) — 8 sitios migrados, 11 suelos → 4, ratchet propio |
| N·1 romper el espejo | ✅ CERRADO (0ac5a17 + 7b4818c) — gate 6/6 VERDE en vivo: se renombró un schema y la lectura siguió funcionando · inerte por defecto |
| N·2a verificar CloudEvents | ✅ MEDIDO — y el resultado reordena N·2: ver §4.1 |
| N·2b primitiva de pull + sombra | ✅ HECHO Y MEDIDO (5e56d9d) — row_count y column_count: cero deriva en 15/15; size_bytes diverge en 15/15 y NO es el mismo número (§4.5) |
N·2c row_count copia → proyección | ✅ HECHO (15a1657) — reconciliador + scheduler opt-in, dry-run; medido en vivo 15/15 match, 0 deriva. INERTE hasta ENABLE_LAKEHOUSE_RECONCILE |
| N·2c-bis retirar los reportes de escritor | ✅ RESUELTO — y la premisa era FALSA (1d7f602): los caminos nativos RELEVAN o VERIFICAN el total-records, no lo copian. Lo que sí era caro (un count(*) por DML) sí se retiró |
Lo que midió el gate de N·1 en vivo (2026-07-30), y no estaba en el plan:
datasets.created_byydatasets.project_idson NOT NULL en la BD viva. Lo segundo confirma empíricamente lo que declaró20261249: project & files es portante, no legacy. Estaba refutado leyendo migraciones; ahora está medido./lakehouse/configdel despliegue:pyiceberg 0.11.1(confirma el supuesto del GATE v3),catalog_type=rest,replace_preserves_history=true, ynamespace=main.default— la trampa delLAKEHOUSE_NAMESPACE=datasetsque avisaba INFRA.md ya está corregida en el runtime.- Una aserción del gate llevaba
|| truey pasaba siempre. Una comprobación que no puede fallar no es una comprobación. - ml-runner no expone un
drop, así que el gate deja una tabla huérfana por corrida. La recoge el barrido de retención.
Lo que se aprendió cableando el CI, y no estaba en el plan:
- El único workflow del repo llevaba rojo en CADA push del día — cuatro seguidos. Y no por los tests:
No space left on deviceinstalando el stack ML completo (torch + CUDA + autogluon) desderequirements-dev.txt. Los tests de ml-runner no se han ejecutado en CI ni una vez hoy, incluidos los tests gateados por pyiceberg que existen precisamente porque no corren en local. Es un fallo de infra disfrazado de fallo de tests, y es la mejor ilustración posible de por quéguardstenía que nacer verde. - duck-server no tenía CI ninguno pese a servir hoy el tráfico del SQL Editor. Ahora corre sus 100 tests en 26 s.
- El baseline informativo da 17 fallos en CI frente a 18 en local (uno menos, diferencia Linux/Windows). Sirve como ancla: si sube, es nuevo.
1 · N·-1 · Cablear el ratchet a CI (prerequisito, no fase) — ✅ HECHO
Todo lo que sigue se apoya en que la superficie no crezca mientras se retira. Hoy ese apoyo no existe: check:dataset-rows-seam es un comando manual, y el único workflow del repo está filtrado a services/ml-runner/**.
Trabajo: un workflow que corra en cada push los tres ratchets (el de dataset_rows, el de FQN, y el nuevo de §2) + tsc + vitest. Quince líneas.
Por qué va primero: sin esto, cada fase de abajo se puede deshacer sola en el siguiente PR y nadie se entera. Es el paso más barato del plan y el que sostiene a los demás.
2 · N·0 · Un solo resolvedor (sin cambio de comportamiento) — ✅ HECHO (7dd8035)
Resultado medido: 8 sitios migrados, el duplicado de
compensate.tsborrado, y los suelos autorizados bajan de 11 a 4 (quien lo define · el resolvedor · el nacimiento de la tabla · el último recurso deresolveIdentity).tsc0 · 1265/1265.Dos cosas que salieron al hacerlo y conviene no repetir:
· El ratchet contaba PROSA. La primera versión contaba las menciones dedefaultIcebergNamespace()en JSDoc y daba un falso positivo enfqn.ts. Un trinquete que cuenta comentarios acaba desactivado — ahora quita comentarios y descuenta la definición. Y se verificó que caza: inyectada una regresión real enchecksum-client, falló con el motivo correcto.
· Alinear un fixture puede borrar la cobertura que importa. El test deread-clientomitía el namespace, así que la migración lo hizo disparar la resolución; alinearlo con la llamada real (Junction siempre lo pasa) lo puso verde y borró justo la cobertura del fallback, que es lo que N·0 cambia. Por eso la propiedad se fija aparte ennamespace-resolution.test.ts.
Objetivo: que sea imposible obtener un namespace sin resolverlo. Hoy y con los datos de hoy, lo resuelto y lo asumido coinciden — por eso esta fase es inerte y por eso se puede hacer sin canary.
Trabajo, en orden:
- Ampliar el chokepoint a la identidad completa.
resolveDatasetNamespace(datasetId)→resolvePhysicalIdentity(datasetId), que devuelve{ namespace, tableName, identifier }. La mitad del nombre de tabla ya existe (datasetTableName); esto sólo la reúne con la del namespace para que nadie tenga que componerlas a mano. - Borrar el duplicado de
compensate.ts:127. - Migrar los ocho sitios que asumen. Regla:
opts.namespacedeja de tener fallback; si el llamante no lo trae, se resuelve. Los dosconst NAMESPACE = …a nivel de módulo se borran (congelan el valor al import, que es la peor forma del bug). - El ratchet:
scripts/check-namespace-resolution.ts— falla sidefaultIcebergNamespace()se llama fuera dedataset-namespace.tsyfqn.ts. Allowlist explícita como el del seam, con conteo por fichero para que sólo pueda bajar.
Qué NO se toca: datasetTableName / native_table_identifier y su test de conformidad (§0.1). Siguen siendo correctos.
Reversible: sí, trivialmente — es refactor puro.
Cómo se sabe que está bien: tsc 0, suites verdes, y el ratchet en verde con la allowlist a 2.
3 · N·1 · Romper el espejo (12 líneas + un canary) — ✅ CÓDIGO HECHO (0ac5a17)
Forma ratificada por el owner:
{env}.w_{workspace}. Implementada en
lib/lakehouse/opaque-namespace.ts(módulo puro, 20 tests) y cableada al único
sitio que acuña:deriveTargetNamespaceeniceberg-native-write.ts.Inerte por defecto. Hacen falta DOS env-vars y ambas están ausentes:
LAKEHOUSE_ENV(el token de entorno) yLAKEHOUSE_OPAQUE_NS_WORKSPACES(el
canary, por workspace). Dos y no una porque el namespace se pinea al
commitear: un valor mal acuñado es permanente para esa tabla.Verificado ANTES de tocar, y era lo que podía tumbar la fase: nadie parsea el
namespace de vuelta a catalog/schema —se trata como cadena opaca en todo el
stack— y el barrido de retención ya mandanamespace: nullpara cubrir TODOS los
namespaces, así que multiplicarlos no lo rompe.Pendiente: correr el gate en vivo (
scripts/dataspaces/n1-opaque-namespace-gate.ts),
que necesita las dos env-vars + ml-runner/Lakekeeper/R2. Es lo que convierte
«renombrar ya no mueve datos» de propiedad razonada en propiedad medida.
El planteamiento original
Objetivo: que el namespace físico deje de significar nada de la taxonomía.
El principio, que es lo que hay que retener:
El namespace físico debe espejar lo INMUTABLE (el tenant), no lo EDITABLE (la taxonomía). Hoy espeja
{catalog.slug}.{schema.slug}— dos cosas que el usuario puede renombrar. Por eso renombrar es imposible y por eso hizo falta el pin defensivo.
La forma propuesta: {env}.w_{workspace_uuid sin guiones}
w_<workspace>— el tenant es inmutable, así que el nombre nunca miente. Y hay un bonus real: Lakekeeper autoriza con OpenFGA, que hace herencia jerárquica sobre namespaces — un namespace por workspace convierte la tenencia en algo aplicable en el catálogo, gratis, que es exactamente lo que N·3 va a necesitar.{env}.— preserva la separación dev/prod de §0.3, que hoy es el namespace.enves inmutable para un despliegue dado, así que no reintroduce el problema.
Trabajo: sustituir el cuerpo de deriveTargetNamespace (iceberg-native-write.ts:110-121). Un llamante (resolveWriteNamespace:146), sólo en el nacimiento de la tabla.
Por qué no hay migración: datasets.iceberg_namespace ya es NULLABLE y se pinea al commitear (:334). Las tablas existentes conservan su ubicación; las nuevas nacen opacas. Los dos regímenes conviven sin coordinación — que es justo lo que resolveWriteNamespace ya sabe hacer.
Canary: por workspace, no global. Un workspace de prueba estrena el namespace opaco; el resto sigue naciendo como hoy.
Gate antes de encender: un harness que cree un dataset en el workspace canary, escriba, lea por las dos rutas (DuckDB y ml-runner) y renombre su schema, comprobando que la lectura sigue funcionando. Ese renombrado es la prueba: hoy no se puede hacer.
Reversible: sí — apagar el canary. Las tablas ya nacidas se quedan donde están y se resuelven igual, porque su ubicación está guardada.
4 · N·2 · La suscripción, en sombra ⭐
Es la fase de mejor ratio del plan y la que puede invalidar el resto. Por eso su primera mitad es medir, no construir.
4.1 · Verificar la premisa — ✅ MEDIDO (2026-07-30). El resultado reordena N·2.
Lo medido, y es un no cualificado:
· Lakekeeper está vivo y sano (
/health200, read_pool + write_pool ok,maintenance_mode: off), pero todo lo demás exige auth y la credencial del catálogo vive en Railway, no en local. No se puede leer su configuración desde aquí.
· Los únicos sinks de CloudEvents son NATS y Kafka. No hay sink HTTP ni webhook. Y los backends se movieron a crates aparte (lakekeeper-events-nats,lakekeeper-events-kafka), así que ni siquiera está garantizado que estén compilados en la imagen que corremos.
·LAKEKEEPER__LOG_CLOUDEVENTS=truesólo escribe líneas de log. No es un stream durable ni consumible: no da la garantía en la que se apoyaba el diseño.
· No corremos ni NATS ni Kafka. Consumir el stream exige levantar un broker — infraestructura nueva que el approach había valorado como «medio» sin saberlo.⚠️ Corrección al diseño: index-frontier.md apoyaba N·2 en que «el catálogo ya nos cuenta lo que pasa, exactly-once». Es cierto como capacidad del producto y falso como capacidad disponible hoy: hay emisor, no hay receptor, y ponerlo cuesta un servicio más.
PERO — y esto es lo que cambia el plan — la tesis NO dependía de CloudEvents tanto como el diseño daba a entender. La propiedad portante es «los hechos se DERIVAN, no se COPIAN». Un poller deriva igual de bien que un suscriptor: la diferencia es latencia y la capacidad de enterarse de cambios hechos por motores que no controlamos. Los escritores dejan de reportar en los dos casos, que es el 90 % del valor.
Plan revisado — pull primero, push después si la latencia lo pide:
- N·2b por PULL. Reusar la maquinaria que YA existe (
ratify+attribution-client) para proyectar. Coste real: un endpoint en ml-runner que devuelva el summary del snapshot ACTUAL de una tabla — hoy/lakehouse/operation-statusexige unoperation_idy/verifycompara contra PG en vez de desacoplar.- Sombra igual: comparar lo derivado contra lo que los escritores reportaron, sin corregir nada, y medir la deriva.
- NATS sólo si hace falta. Es la optimización de push sobre pull, y se puede decidir con datos de latencia en vez de por fe. Es el sink más barato (un binario pequeño).
El criterio de parada escrito no se disparó — decía «si el stream pierde o duplica, repliégate al plan W». Lo que pasó es distinto y mejor: el stream no es alcanzable sin infra nueva, y hay un camino que no la necesita y reusa lo construido.
El planteamiento original
Lakekeeper emite CloudEvents en cada cambio de tabla, con manejador de auditoría exactly-once desde 0.12.0; corremos 0.13.0. Pero es una capacidad documentada del producto, no una medición de nuestro despliegue — un barrido del repo por CloudEvent da cero.
Primer paso, y es de operación, no de código: comprobar en el servicio de Railway si el emisor está configurado y a dónde apunta. Si no lo está, activarlo es config.
Criterio de parada, dicho por adelantado: si el stream pierde o duplica eventos, la tesis «proyección, no copia» se cae y hay que replegarse al plan W (derivar del snapshot + reconciliar). Es una respuesta válida y barata de obtener — por eso va primero.
4.2 · El receptor, sin autoridad
Un endpoint que recibe eventos y los escribe tal cual en una tabla nueva, catalog_events. Sin interpretarlos, sin tocar nada existente. Deduplicación por el id del evento (que es lo que hace útil el exactly-once).
Invariante de esta fase: catalog_events no tiene ni un lector en el camino caliente. Es un log, no una fuente.
4.3 · El comparador
Un job que, por cada evento, responde: ¿lo que el catálogo dice que pasó coincide con lo que el escritor reportó?
Las tres comparaciones que importan, y por qué son exactamente estas:
- ¿existe la txn de ledger correspondiente? → mide si algún escritor se olvidó de abrir.
- ¿
iceberg_sync_logtiene su fila? → mide el reporte que hoy es obligatorio «para que el sistema no se rompa solo». - ¿
datasets.row_countcoincide con eltotal-recordsdel snapshot del evento? → mide la copia triplicada.
Salida: una divergencia por tipo, por día. Nada se corrige todavía.
4.5 · ⭐ Lo que midió la sombra (2026-07-30) — y una de las tres no era una copia
Corrida sobre 15 datasets nativos con el endpoint ya desplegado:
| Hecho | Deriva | Veredicto |
|---|---|---|
datasets.row_count ↔ total-records | 0 / 15 | ✅ listo para derivar. Es el que tiene 28 escritores y está triplicado |
datasets.column_count ↔ esquema del catálogo | 0 / 15 | ✅ listo (descontando las 3 columnas de identidad, que el catálogo cuenta y column_count no) |
datasets.size_bytes ↔ total-files-size | 15 / 15 | ❌ no es el mismo número |
| tablas con delete files | 0 / 15 | la reserva del merge-on-read no aplica hoy — total-records es exacto |
size_bytes NO es una copia de un hecho del catálogo, y el código lo dice.
writer.py:245 → size_bytes=arrow_table.nbytes, # in-memory size (indicative).
Es el tamaño en memoria (Arrow); total-files-size son los bytes de Parquet
en disco. La firma de la deriva lo confirma sin lugar a dudas: en las tablas
grandes Parquet comprime (bronze_order_items PG 22,1 MB → cat 11,3 MB) y en las
diminutas el footer domina (caretakers PG 356 B → cat 3,5 KB).
Consecuencia para el plan: warehouse-scaffolding-retirement.md §4 metía
row_count,column_countysize_bytesen el mismo saco («salen con W·1»). Son dos casos distintos. Los dos primeros se DERIVAN —mismo número, cero deriva medida—. El tercero se SUSTITUYE: pasar atotal-files-sizecambia lo que la cifra SIGNIFICA para todo consumidor. Es probablemente una mejora («cuánto ocupa esta tabla» es una pregunta de disco, ynbytesno la contesta), pero es un cambio de semántica y hay que auditar a los consumidores, no colarlo dentro de una derivación.
Y esto es exactamente para lo que servía la sombra: el plan habría derivado las tres a la vez y habría cambiado en silencio el significado de una.
4.6 · ⚠️ N·2c-bis — la premisa de «retirar los reportes» era falsa
El plan decía: «derivar row_count elimina un reporte obligatorio por escritor», y de ahí salía la tarea «retirar los ~15 reportes de los escritores respaldados por Iceberg». Al ir a hacerlo, no se sostiene. Los cuatro caminos nativos del writer no inventan el número:
| Camino | Qué devuelve | |
|---|---|---|
write_append (:505-516) | total if total is not None else … | releva el total-records |
write_upsert (:570) | idem | releva |
commit_add_files (:899) | recorded if recorded is not None else … | releva |
write_overwrite (:242) | rows=written — pero con una aserción previa (:232-237) que LANZA si recorded != written | verifica contra el catálogo |
O sea: el número o se releva del snapshot o se verifica contra él. No es una copia que pueda driftar en silencio — que es exactamente por qué la sombra midió 0/15. Quitar esas escrituras no elimina una copia: sólo abre una ventana de staleness hasta la siguiente pasada del reconciliador. No se hace.
Lo que SÍ era una copia cara, y se retiró: tras cada DML la puerta lanzaba un SELECT count(*) —un escaneo completo— sólo para aprender el total, porque un DML devuelve el delta. Ahora se pregunta al catálogo (metadata) y el escaneo queda como fallback de corrección, no defensivo: bajo merge-on-read total-records deja de ser el conteo visible, y el DML de DuckDB produce delete files exactamente.
Lo que esto enseña sobre el encuadre entero: «28 escritores estampan
row_count» es cierto y engañoso. Lo que importa no es cuántos escriben, sino de dónde sacan el número. Los que lo relevan del catálogo no son el problema; el problema son los que lo calcularían por su cuenta — y ésos son los motores que aún no existen (Karma) o los que no pueden marcar su commit (DuckDB). El reconciliador de N·2c es la red que los cubre por adelantado, no una limpieza de deuda presente.
4.4 · El flip, por consumidor y no de golpe
Cuando la sombra lleve N días limpia, los hechos pasan a proyectarse del stream de uno en uno, empezando por el más barato de revertir:
datasets.row_count— el que tiene 28 escritores.iceberg_sync_log— y con él, el reporte obligatorio de cada escritor.status/committed_atdel ledger — ⚠️ este NO antes de W·2: el oráculo de frescura se alimenta de ellos, y derivarlos con un job asíncrono mandaría a los lectores STRICT a un Postgres vacío durante la ventana de reconciliación. La dependencia está medida en warehouse-scaffolding-retirement.md §5.
Lo que cambia para ratify: deja de ser un detector que adivina y pasa a ser un verificador con fuente. No desaparece — el unverifiable de DuckDB sigue existiendo mientras el motor no pueda marcar su commit — pero deja de ser la única forma de saber qué pasó.
5 · Orden, y qué mide cada fase
| Fase | Coste | Reversible | Qué MIDE (no qué hace) |
|---|---|---|---|
| N·-1 ratchet en CI | muy bajo | sí | que la superficie no crece |
| N·0 un solo resolvedor | bajo | sí | cuántos sitios asumían (hoy: 8) |
| N·1 romper el espejo | bajo | sí, por canary | si renombrar un schema es posible ← la prueba de que el espejo se rompió |
| N·2a verificar CloudEvents | muy bajo | n/a | si la premisa central es cierta |
| N·2b sombra | medio | sí | la deriva real entre catálogo y control-plane |
| N·2c flip por consumidor | medio | sí, uno a uno | cuántos escritores dejan de reportar |
N·0 y N·1 no dependen de N·2. Se pueden hacer en paralelo, y N·2a puede lanzarse el primer día porque es una consulta de operación.
6 · Lo que NO se toca en este tramo, y por qué
- La cara Iceberg REST de Index (N·3). Es la fase cara y la única que mete a Index en el camino de resolución. No se empieza hasta que N·2 haya demostrado que el modelo de proyección funciona: si no funciona, N·3 cambia de forma.
datasetTableName/native_table_identifiery su test de conformidad. Ya son correctos (§0.1).- El CAS. Sigue siendo de Lakekeeper, siempre. Nada de este tramo lo roza.
dataset_rowsy el ledger. Son el plan W, y su orden ya está medido — W·2 antes que W·3.
7 · Decisiones antes de empezar
- ¿La forma del namespace opaco es
{env}.w_{workspace}? Es la propuesta de §3. Lo que hay que aceptar con ella: el namespace deja de ser legible en la UI de Lakekeeper — y eso es el objetivo, no un efecto secundario, porque el nombre legible pasa a vivir sólo en Index. La alternativa (un warehouse de Lakekeeper por entorno) es más limpia conceptualmente y más cara de operar. - ¿Se acepta que N·2a pueda matar el plan? Recomendación: sí, y explícitamente. Un primer paso que no puede invalidar la tesis no está midiendo nada.
- ¿Quién autoriza, Index u OpenFGA? Heredada de index-frontier.md §7·3. No bloquea N·0–N·2, sí bloquea N·1 si se quiere cobrar el bonus de tenencia del namespace por workspace. Conviene tomarla en este tramo aunque se aplique en el siguiente.
Cruza con: index-frontier.md (el diseño) · warehouse-index.md (qué es Index) · warehouse-scaffolding-retirement.md (el plan W, con el que N·2c se coordina) · INFRA.md (la topología y la separación dev/prod).