Published

HANDOFF — Junction (el Furnace de Carbon). Estado + cómo continuar

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

FaseEstado
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 puertasirviendo 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 destructivoR·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 puertacableado 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, nombradowarehouse-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) y runQuery (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 propio jsonbCast sobre PG. Es F6.
  • studio.digest (digest-worker.ts:123) — lector directo al router.
  • duckColumns (sql-editor:688) — llamada directa a duck-server para DESCRIBE (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:

  1. Lakekeeper es la única autoridad sobre el HECHO. El ledger PG nunca decide si un snapshot existe.
  2. Postgres conserva la INTENCIÓN (durable, pre-data-plane) y una proyección sin autoridad.
  3. Ningún estado terminal se infiere del control-flow.
  4. UNKNOWN es de primera clase, nunca terminal por defecto.
  5. apply() PUBLICA — no se promete aislamiento (ningún motor del stack sabe escribir Iceberg sin publicar).
VerboDóndeEstado
openel insert del ledger en withNativeControlPlaneya existía
applyel closure ingestya existía
ratifylib/compute/ratify.ts✅ escrito · probado en vivo
compensatelib/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ógnitaEstado
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.
2Node no puede preguntarle al catálogoCERRADA. POST /lakehouse/operation-status + lib/lakehouse/attribution-client.ts.
3constraint de dataset_transactions.statusCERRADA por medición directa (§5).
4WAP con branches (refs en Lakekeeper)🔓 abierta — es el norte, no el plan
5row_count delta vs total🔓 abierta — inconsistente ya entre primitivas de ml-runner
6replace destructivoCERRADA (R·0→R·4 medidos en vivo). Queda un sub-abierto: expirar no libera espacio. junction-replace-lineage.md
7row_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»
8Un 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 tiene CREATE TABLE en 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 columna type), 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, falta KARMA_SQL_URL) · PG (legacy).
  • replace = drop_table + create (writer.py:530) ⇒ sin historia, sin time-travel. Ver §6.
  • pyiceberg: append/overwrite/add_files aceptan snapshot_properties; upsert no. No expone rollback_to_snapshot ni set_current_snapshot (solo tags/branches). Evolución de esquema metadata-only vía update_schema().
  • ml-runner NO tiene endpoint SQL ni lectura por snapshot.
  • Secretos: DUCK_SERVER_URL/DUCK_JWT_SECRET los pasa el owner inline; ML_RUNNER_* y LAKEHOUSE_* están en .env.local. ENABLE_ICEBERG_SYNC=true en el servicio Railway "Carbon Jobs".
  • tsc del proyecto es grandeNODE_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:88 es 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 en INFRA.md:37. Empieza siempre por GET /lakehouse/config.pyiceberg_version.
  • pyiceberg 0.11.1 no sabe escribir metadata v3 (TableMetadataV3.model_dump_jsonNotImplementedError, apache/iceberg-python#1551). Pero en el path REST no acuña el metadata: reenvía properties al servidor. Quien decide la format-version es Lakekeeper, no nosotros.
  • Hay TRES puntos de creación de tabla, no uno: _create_native_table, un create_table desnudo en write_overwrite:217 (código muerto, sin llamadores) y el CTAS de DuckDB.
  • El ratchet check:dataset-rows-seam NO corre en CI — es un comando manual, y su perímetro no ve los 6 RPC que escriben dataset_rows desde 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_id NO son legacy: 20261249 revocó la etiqueta de D1 — «No hay D5 para estas columnas».
  • __row_id no es estable en replace/append sin clave: se re-acuña un uuid4 por fila en cada snapshot (ingest_service.py:317-319).
  • __row_index no es una columna, es un protocolo: el fan-out reserva rangos disjuntos de 2**40 con una columna row_index_base bigint NOT NULL en tabla de control.
  • row_count está 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 de tier en CREATE VIEW que este handoff avisaba ya está commiteado en 937424a; 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 en services/.
  • 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.

HuecoCommitQué era
H4 /columns sin guarda8fa079cf-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 path989efdbextractTableNames no veía nada tras una coma ⇒ read_parquet('s3://…') no entraba en baseTables y la tenencia no lo miraba
H6 ventana deltae241e86pérdida silenciosa y permanente de filas: JOIN vacío, 0 anexadas, watermark avanzado, build en success
D·2 verbos en la puertae241e86só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_all no expone metadata — de las tres tablas del ledger, sólo dataset_transactions la 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_APPLY para 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 escribe edc_transfer_id mientras el tipo lee edcTransferId). 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.