HANDOFF — Junction (el Furnace de Carbon). Estado + cómo continuar
Lee esto primero. Handoff vivo, act. 2026-07-29. Construimos Junction: la puerta única y agnóstica de motor de Carbon, creciendo de la facade que ya existía. El eje READ está cerrado y sirviendo tráfico; el eje WRITE tiene su protocolo (JOP) escrito y probado en vivo, pero todavía no gobierna ninguna escritura. El siguiente paso concreto está en §8.
0 · TL;DR — dónde estamos
| Fase | Estado |
|---|---|
F1 contrato v1.2 + hogar lib/compute/ | ✅ desplegado, inerte |
F2 substrato ⊥ motor + EngineAdapter | ✅ desplegado, inerte |
| F3 equivalence harness + cintura unificada | ✅ desplegado · whitelist DERIVADA de ejecución real |
| F4a el SQL Editor pasa por la puerta | ✅ sirviendo tráfico; bypass duck-direct CERRADO |
| B1/B2 reparar el control-plane nativo | ✅ desplegado |
| Pérdida de datos del mirror | ✅ cerrada (estaba ARMADA en prod) |
| JOP los 4 verbos | ✅ escritos y probados en vivo · ratify ya tiene scheduler (opt-in, dry-run, con cortacircuitos) · faltan los 35 escritores |
replace destructivo | ✅ R·0→R·4 cerrados y medidos en vivo (R·4 18/18, canary ON en main.test; R·3 ejecutado: 133 tablas, 3 snapshots expirados) — ⚠️ queda que expirar no libera espacio |
| F4b el write bajo la puerta | ✅ cableado y desplegado (532bb52 + 0a40cdb) · DML gobernado, medido en vivo · canary por dataset (animal_events, main.test) |
| D·0 matriz de verbos de DuckDB | ✅ medida (6fbaacb) — sobre tabla nativa sólo DELETE y MERGE; INSERT/UPDATE los rehúsa la extensión, y esa protección es accidental |
| Index el pilar 3, nombrado | ✅ warehouse-index.md — la gobernanza PG deja de llamarse «control-plane» |
main = 3a64ba4, todo pusheado. Railway y Vercel al día.
Los tres pilares, con sus nombres definitivos: ① bytes (Parquet/R2) · ② formato+catálogo (Iceberg+Lakekeeper, la cintura) · ③ Index (la gobernanza en Postgres — nuestro Unity Catalog). Junction es la puerta delante de los tres, no un pilar. Regla de nombrado en warehouse-index.md §6.
1 · Qué ES Junction
La puerta única donde cualquier superficie declara QUÉ quiere —un item que consumir, o una query que ejecutar— con un contexto de gobernanza, y recibe datos sin saber qué motor ejecutó ni dónde viven los bytes. Desacopla plan↔ejecución (arquetipo Furnace). NO es el catálogo (Lakekeeper), NO es un motor (DuckDB/pyiceberg/Karma van detrás), NO es storage (R2).
Los ficheros que SON Junction:
lib/compute/contract.ts— LA PIEDRA (solo tipos; todo deriva de aquí).v1.2.lib/compute/junction.ts— el COMPOSER. Dos entradas peer:loadItemForConsumption(items) yrunQuery(live-SQL, ya activa).
Docs: junction.md (qué/por qué) · junction-execution-map.md (touch-points).
2 · El eje READ — cerrado
F4a: la rama de motor del SQL Editor pasa por runQuery. Gate en vivo: scripts/duckdb/f4a-door-parity.ts corre el MISMO SQL por las dos rutas y las compara con el comparador de F3 → 12/12 paridad + 5/5 rechazos, incluida una vista compleja de 17 columnas.
Lo que la puerta AÑADE y el bypass no tenía: tenencia POR TABLA (una query cross-workspace se rechaza), rechazos tipados (QueryRejectedError) distintos del error del motor, y el stripping de columnas __ detrás de la puerta.
⚠️ Lo que NO está bajo la puerta (no lo des por hecho):
dashboards/query/route.ts:622— sigue completamente fuera, con su propiojsonbCastsobre PG. Es F6.studio.digest(digest-worker.ts:123) — lector directo al router.duckColumns(sql-editor:688) — llamada directa a duck-server paraDESCRIBE(plano de catálogo, decidido).information_schema— ejecuta en PG. Decidido: se queda.
3 · El eje WRITE — JOP, escrito y probado, pero inerte
Entregable del paradigma: la decisión completa está en el resultado del workflow de diseño; el resumen operativo es éste.
JOP = open → apply → ratify → compensate. Cinco invariantes:
- Lakekeeper es la única autoridad sobre el HECHO. El ledger PG nunca decide si un snapshot existe.
- Postgres conserva la INTENCIÓN (durable, pre-data-plane) y una proyección sin autoridad.
- Ningún estado terminal se infiere del control-flow.
UNKNOWNes de primera clase, nunca terminal por defecto.apply()PUBLICA — no se promete aislamiento (ningún motor del stack sabe escribir Iceberg sin publicar).
| Verbo | Dónde | Estado |
|---|---|---|
open | el insert del ledger en withNativeControlPlane | ya existía |
apply | el closure ingest | ya existía |
ratify | lib/compute/ratify.ts | ✅ escrito · probado en vivo |
compensate | lib/compute/compensate.ts | ✅ escrito · probado en vivo |
La regla que lo define: ratify es el ÚNICO sitio autorizado a escribir aborted, porque es el único que puede demostrarlo. Cuatro veredictos: committed / aborted / unverifiable / unknown.
No se borra el ledger PG (era el instinto, y es erróneo): es el registro de procedencia POR FILA del camino incremental y sostiene commits sin snapshot. (Antes decía «el índice de procedencia»; reescrito para no colisionar con el nombre del pilar 3 — ver warehouse-index.md §6.) Dos precisiones medidas: (a) el camino incremental fuerza Postgres cuando hay ventana delta — está cableado a propósito (source-resolver.ts:74: «la ventana delta es un concepto del ledger PG, sin equivalente en un snapshot Iceberg»), y la temp de hidratación ni siquiera tiene columna transaction_id; (b) hay un segundo consumidor de procedencia por fila, el exportador de tablas (run-export.ts:388-391) — y ya enseña el patrón de salida: para un dataset nativo pone transactionIds=null y lee el snapshot entero en vez de buscar otro índice. Los «commits sin snapshot» los produce pipeline_build_transactions (checkpoint-prepass.ts:92-98), no dataset_transactions.
Tipos en dos ejes: Verb (auditoría) × Effect (gates). UPDATE/DELETE/MERGE → MUTATE rompe la incrementalidad. La BD ya admite los cuatro tipos (§5).
4 · Las incógnitas — cerradas y abiertas
| # | Incógnita | Estado |
|---|---|---|
| 1 | «¿aterrizó la MÍA?» | ✅ CERRADA. El txnId del ledger ES el operation_id (durable, pre-data-plane, cero identificadores nuevos) → viaja a snapshot_properties del commit. Verificado en vivo: landed=true con SU snapshot. |
| 2 | Node no puede preguntarle al catálogo | ✅ CERRADA. POST /lakehouse/operation-status + lib/lakehouse/attribution-client.ts. |
| 3 | constraint de dataset_transactions.status | ✅ CERRADA por medición directa (§5). |
| 4 | WAP con branches (refs en Lakekeeper) | 🔓 abierta — es el norte, no el plan |
| 5 | row_count delta vs total | 🔓 abierta — inconsistente ya entre primitivas de ml-runner |
| 6 | replace destructivo | ✅ CERRADA (R·0→R·4 medidos en vivo). Queda un sub-abierto: expirar no libera espacio. junction-replace-lineage.md |
| 7 | row_count derivable del catálogo | 🔓 abierta — la derivación YA existe (ratify.ts:142 lee total-records) pero escribe en dataset_transactions, no en datasets. Falta el endpoint «total del snapshot actual» |
| 8 | Un motor que no puede marcar su commit | 🔓 abierta — DuckDB abre su txn con attributable:false; sin snapshot_properties en duck-server, ninguna derivación puede rescatarlo |
Limitación real, no disimulada: table.upsert() de pyiceberg no acepta snapshot_properties, así que el path sync.incremental queda sin atribución. El escritor lo DECLARA en el ledger (metadata.attributable) y ratify trata su ausencia como unverifiable — nunca como aborted.
5 · Hechos verificados del stack (no re-derivar)
- Cintura = Iceberg sobre R2, catálogo Lakekeeper (REST). El commit atómico es su CAS sobre
metadata.json. dataset_transactions(BD viva, medido):status text DEFAULT 'open' CHECK (open|committed|aborted)·type text CHECK (SNAPSHOT|APPEND|UPDATE|DELETE)→ el vocabulario completo YA vale; B6 no necesita migración. ⚠️ La tabla NO tieneCREATE TABLEen migraciones (drift): existe en prod por historia, un entorno nuevo no la tendría.- Tres tablas de ledger unidas por la vista
dataset_transactions_all:dataset_transactions(861 filas, el real),sdk_dataset_transactions(5, sin columnatype),pipeline_build_transactions(8,status DEFAULT 'committed'→ nace committed, sin ciclo de vida). - Motores: DuckDB (
duck-server, vivo, whitelist de 25 features DERIVADA de ejecución real) · pyiceberg (ml-runner, writer bulk) · Karma (inerte, faltaKARMA_SQL_URL) · PG (legacy). replace=drop_table+ create (writer.py:530) ⇒ sin historia, sin time-travel. Ver §6.- pyiceberg:
append/overwrite/add_filesaceptansnapshot_properties;upsertno. No exponerollback_to_snapshotniset_current_snapshot(solo tags/branches). Evolución de esquema metadata-only víaupdate_schema(). - ml-runner NO tiene endpoint SQL ni lectura por snapshot.
- Secretos:
DUCK_SERVER_URL/DUCK_JWT_SECRETlos pasa el owner inline;ML_RUNNER_*yLAKEHOUSE_*están en.env.local.ENABLE_ICEBERG_SYNC=trueen el servicio Railway "Carbon Jobs". - tsc del proyecto es grande →
NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit.
Añadidos 2026-07-29 (26 agentes contra el código; no re-derivar):
- Nada del stack de formato está pineado.
requirements.txt:88es un rango (pyiceberg>=0.8,<1.0) sin lockfile → la imagen resuelve la versión en cada build. La extensión Iceberg de DuckDB se instala por nombre contra el repositorio. La versión de Lakekeeper sólo existe como prosa enINFRA.md:37. Empieza siempre porGET /lakehouse/config.pyiceberg_version. - pyiceberg 0.11.1 no sabe escribir metadata v3 (
TableMetadataV3.model_dump_json→NotImplementedError, apache/iceberg-python#1551). Pero en el path REST no acuña el metadata: reenvíapropertiesal servidor. Quien decide la format-version es Lakekeeper, no nosotros. - Hay TRES puntos de creación de tabla, no uno:
_create_native_table, uncreate_tabledesnudo enwrite_overwrite:217(código muerto, sin llamadores) y el CTAS de DuckDB. - El ratchet
check:dataset-rows-seamNO corre en CI — es un comando manual, y su perímetro no ve los 6 RPC que escribendataset_rowsdesde dentro de Postgres. - Los 8 lectores STRICT están en
defaultMode: 'off': para ellos el veredicto del oráculo de frescura es hoy irrelevante (caen a PG siempre,read-router.ts:126). datasets.project_id/file_idNO son legacy:20261249revocó la etiqueta de D1 — «No hay D5 para estas columnas».__row_idno es estable enreplace/appendsin clave: se re-acuña un uuid4 por fila en cada snapshot (ingest_service.py:317-319).__row_indexno es una columna, es un protocolo: el fan-out reserva rangos disjuntos de2**40con una columnarow_index_base bigint NOT NULLen tabla de control.row_countestá TRIPLICADO (datasets+dataset_transactions+iceberg_sync_log) y lo estampan 28 sitios vivos — 23 TS + 5 RPC dentro de Postgres. Cero escritores Python.
6 · Lo que se arregló por el camino (y por qué importa)
🛑 Pérdida de datos del mirror PG→Iceberg — estaba ARMADA en producción. manual.table escribe nativo (deja dataset_rows vacío) pero su route inserta siempre una fila en pipeline_build_transactions; el poller calculaba is_native mirando solo dataset_transactions ⇒ publishable ⇒ overwrite de 0 filas sobre una tabla con datos. Medido antes de tocar: 13 datasets expuestos (hasta 112.650 filas), cero daño. Cerrado con dos barreras independientes: un guard anti-destrucción ciego a la procedencia + is_native por dataset. Diagnóstico reusable: scripts/dataspaces/iceberg-mirror-exposure.ts.
B1/B2 — el estado terminal ya no se infiere del control-flow. Los pasos 3/4/5 de withNativeControlPlane no comprobaban {error} (supabase-js no lanza), y el catch marcaba aborted incluso con el snapshot ya commiteado. aborted desapareció de la primitiva: ninguna rama lo escribe. Censo previo: 0 estados imposibles.
F3 — la cintura no era única. fqn.ts ignoraba LAKEHOUSE_NAMESPACE que 11 sitios sí respetan ⇒ DuckDB y ml-runner podían apuntar a tablas físicas distintas. Cerrado + test de conformidad.
Lecciones que se repitieron y conviene no olvidar:
- «Verificable por tsc» solo vale donde el valor viaja tipado. En UI/wire/logs hay que re-tipar la frontera.
- El namespace no se asume, se resuelve — mordió dos veces (F3.2 y el cliente de atribución).
- Probar en vivo encuentra lo que los mocks no: 5 de los bugs de esta iteración salieron ejecutando, no testeando.
7 · Convenciones (no tropezar)
- Responder al owner en ESPAÑOL.
- Nunca
git add -A— añadir por rutas explícitas. (El WIP detierenCREATE VIEWque este handoff avisaba ya está commiteado en937424a; el árbol quedó limpio.) - Harness-first: cada fase tiene su harness en
scripts/. Los que existen:f4a-door-parity,f3-equivalence-harness,jop-e2e,jop-e2e-loop,iceberg-mirror-exposure,control-plane-consistency. - Validar sintaxis Python (
ast.parse) antes de commitear cambios enservices/. - Docs+memoria a estado vigente cada iteración. Los rancios son la fuente de confusión #1.
8 · Cómo continuar — el siguiente trabajo
✅ El SQL Editor — los cuatro huecos, cerrados (2026-07-29)
Entregable con el marcador por pasos: duckdb-conformance.md §5.
| Hueco | Commit | Qué era |
|---|---|---|
H4 /columns sin guarda | 8fa079c | f-string ejecutable con el FQN crudo del body, sobre la conexión que tiene Lakekeeper y las creds R2. ensure_namespace tenía el mismo agujero |
| H5 exfiltración por el read path | 989efdb | extractTableNames no veía nada tras una coma ⇒ read_parquet('s3://…') no entraba en baseTables y la tenencia no lo miraba |
| H6 ventana delta | e241e86 | pérdida silenciosa y permanente de filas: JOIN vacío, 0 anexadas, watermark avanzado, build en success |
| D·2 verbos en la puerta | e241e86 | sólo DELETE y MERGE — los que preservan identidad de fila |
Tres cosas que conviene no re-descubrir:
- La «guarda barata» que el informe proponía para H6 era incorrecta. Excluir las txns nativas de la ventana no arregla nada: una ventana vacía también pasa el gate. Hay que descalificarla.
dataset_transactions_allno exponemetadata— de las tres tablas del ledger, sólodataset_transactionsla tiene.- La SECRET de R2 sigue sin
SCOPE. Se añadió la perilla (DUCK_S3_SCOPE) y el aviso al arrancar; activarla exige confirmar el bucket contra el runtime. La defensa que no depende de ella es la de la puerta.
Lo que queda del editor: H7 (has_identity literal) · H8 (layout y compactación, sin compactador en todo el repo) · duck_require_write_target=True (una env-var).
Prioridad 2 · El replace: cerrado salvo el espacio
Entregable con el estado por pasos: junction-replace-lineage.md. R·0→R·4 están cerrados y medidos en vivo; lo único abierto es que expirar no libera almacenamiento (§7.1 del entregable).
R·0 cerrado, R·1+R·2 desplegados, R·4 medido en vivo (18/18). El canary LAKEHOUSE_REPLACE_PRESERVES_HISTORY está a 1 en el servicio ml-runner de Railway. Medido sobre animal_events de main.test (38b26211-ad93-416d-a1ea-f66aa519b3c5): la tabla pasa de snapshots=1 perpetuo a acumular +2 por operación, compensate fija el snapshot previo REAL con un tag, y una operación sigue confirmable después de que otra escriba encima.
⚠️ Lo que R·4 destapó y no era ruido: el orden de metadata.snapshots que sirve el catálogo no es estable — 8 llamadas idénticas dieron dos órdenes, así que ratify podía informar de 0 filas en una operación que escribió 4. No lo introdujo el linaje (el recorrido inverso anterior ya dependía del array); solo era invisible con un snapshot por operación. Arreglado ordenando por sequence_number. Regla: nada puede depender de la posición en ese array.
R·3 (retención) HECHO y ejecutado en vivo. La política se declara en la tabla (propiedades estándar history.expire.*, 7 días y mínimo 5) y la ejecuta un job nuevo, porque pyiceberg define esas propiedades pero no las aplica. Barrido real: 5 namespaces, 133 tablas, 3 snapshots expirados, tablas íntegras, idempotente. Lo fijado por un tag no se expira nunca, así que la promesa de compensate la sostiene el motor, no la convención. Opt-in: ENABLE_LAKEHOUSE_RETENTION + LAKEHOUSE_RETENTION_APPLY en el servicio de workers (hoy no activado: el barrido se corrió a mano).
⚠️ Lo que R·3 NO resuelve, y hay que decidir: expirar no libera almacenamiento. En pyiceberg 0.11.1 es metadata-only (RemoveSnapshotsUpdate no borra un fichero; no existe remove_orphan_files). Acota la ventana recuperable y el tamaño de metadata.json, pero la factura de R2 sigue creciendo. Tres caminos en §7.1 del entregable; el primero —si Lakekeeper recolecta— se mide con credenciales de R2 vigentes (las de .env.local no validan firma).
Prioridad 2 · Que JOP gobierne de verdad
ratify YA tiene scheduler (lib/workers/lakehouse/ratify-scheduler.ts, opt-in ENABLE_LAKEHOUSE_RATIFY). Cola re-medida antes de agendarlo: 0 open en las tres tablas de ledger, así que nace limpia y cada entrada futura es señal.
Es el único job del sistema que puede escribir aborted, y aborted saca filas de la ventana delta. Por eso lleva dos contenciones que conviene no quitar sin pensarlo:
- Cortacircuitos (
RATIFY_MAX_ABORTS_PER_CYCLE=3): con la cola a cero, N abortos de golpe es más probable que sea un fallo sistémico —catálogo caído, namespace mal resuelto, credenciales— que N escrituras perdidas. Se comprueba antes de procesar, y al saltar el scheduler se detiene a sí mismo: reanudarlo es decisión humana. - Dry-run por defecto (
LAKEHOUSE_RATIFY_APPLYpara aplicar). El veredicto es real; lo que no ocurre es la escritura.
El catálogo caído no puede parecer «ninguna aterrizó»: eso es unknown, no arma el cortacircuitos y no escribe nada.
Lo que falta: migrar los 35 escritores restantes. Ahí «todas las escrituras pasan por Junction» deja de ser propiedad del diseño y pasa a serlo del código. Esta iteración sugiere que la migración encontrará más agujeros de atribución que conversiones mecánicas: el path fan-out recibía el operationId y lo tiraba, así que ratify no podía confirmar ni uno de sus commits.
No verificado en vivo: el camino de aborted. Requeriría fabricar una txn open sintética en el ledger de producción, y una operación que nunca ocurrió no debería quedar escrita en un log de auditoría. Cubierto por tests; committed sí se midió en vivo.
Prioridad 3 · Que JOP gobierne de verdad
Ver arriba. Faltan los 35 escritores.
Prioridad 4 · Retirar el andamio, y estandarizar Index
Las dos líneas nuevas de esta iteración, con sus entregables propios:
- warehouse-scaffolding-retirement.md — W·0.5 (retirar
__created_at: tres columnas de identidad obligatorias, una sin ningún consumidor) es el paso más barato del sistema. Antes de nada: cablear el ratchet del seam a CI, que hoy no lo dispara nadie. - warehouse-index.md — I·1 (las dos fugas medidas del carrier de procedencia: los productores anidan bajo
provenance, el lector aplana; y el path EDC escribeedc_transfer_idmientras el tipo leeedcTransferId). Son bugs, no diseño, y hoy pierden gobernanza en silencio.
Después
F6 (dashboards al mismo gateway) · unificar los 4 registros de commit · WAP con branches (el norte).
Handoff vivo. Ancla: junction.md · warehouse-index.md (el pilar 3) · warehouse-scaffolding-retirement.md (lo que sale) · junction-execution-map.md · junction-f2-engine-adapter.md · junction-f3-equivalence.md · junction-f4-sql-editor.md · junction-b1b2-control-plane-repair.md · junction-replace-lineage.md · duckdb-conformance.md. Memoria: router-junction, warehouse-index-pillar.