Published

Sustrato · 04 — la arquitectura entera, de una pieza

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

Sustrato · 04 — la arquitectura entera, de una pieza

🧊 CONGELADO el 2026-08-11

Sucedido por sustrato-05.md, que es la foto vigente.

⚠️ Este documento se quedó a mitad de camino entre dos estados: nació el 08-10 por
la noche y durante el 08-11 se le fueron añadiendo las filas de la jornada. Sirve como
registro de esa transición —y por eso no se limpia—, pero no es la foto de ningún
momento concreto
, que es justo lo que un sustrato-NN debe ser.

⇒ Para saber qué es verdad HOY: el 05. Para saber qué era verdad el 08-10 por la
noche: este, ignorando las filas marcadas 08-11.

Fecha de la medición: 2026-08-10 (noche). Sucede a
sustrato-03.md (08-10, mañana), sustrato-02.md
(08-09) y sustrato-01.md (08-08), que se conservan sin editar:
la gracia de numerarlos es poder ver qué era verdad en qué momento.

Regla, la de siempre: cada hecho lleva el comando que lo demuestra. Lo que no lo
lleva va marcado como pendiente (§10) u opinión (§11).

Manda sobre cualquier approach o runbook donde discrepen.


⭐ Lo que cambió desde el 03, en cuatro líneas:

  1. 🏁 El camino de escritura del SQL Editor está COMPLETO — lee, escribe firmado
    y reconciliable, crea tablas y CTAS, y un reintento no escribe dos veces.
  2. 🏁 La cola del ledger está a CERO (eran 21), y lo incerrable se cerró con
    evidencia forense, no con un supuesto.
  3. ⭐⭐⭐ Cambio de paradigma identificado: contra Furnace y Rubix, el hueco no es
    de funcionalidad — es que tratamos el SQL como texto. Ver
    mapa-computo.md, que es donde vive esta parte.
  4. Las prioridades están ordenadas por DAÑO, no por vistosidad (§11).

1 · Qué estamos construyendo, en un párrafo

El cliente compra CONTEXTO, no velocidad. Para velocidad están Databricks y
Snowflake. Nuestro producto es conocimiento consumible por agentes, y el
lakehouse es el sustrato que lo hace posible, no el producto.

De ahí salen las tres decisiones que ordenan todo lo demás:

  1. La cintura estrecha es el CATÁLOGO, no un motor. El estándar no es «rápido», es «gobernado y ontologicamente PROYECTABLE».
  2. El cómputo es PLURAL y desechable. Los motores entran y salen por un registro; ninguno es la arquitectura. ⭐ Confirmado por la referencia: es exactamente lo que hace Foundry (Polars por defecto, Spark «sólo cuando el dato excede un nodo»).
  3. La gobernanza emana del catálogo y el grafo la heredará por relación.

2 · El mapa, de arriba abajo

   ┌──────────────────────── SUPERFICIES ────────────────────────┐
   │  SQL Editor · dashboards · pipelines · notebooks · ontología │
   └───────────────────────────┬─────────────────────────────────┘
                    ⭐ JUNCTION — LA PUERTA (lib/compute)
                  loadItemForConsumption  ·  runQuery
                    (nuestro Furnace — ver §7·bis)
              ┌────────────────┴────────────────┐
              │                                 │
     REGISTRY DE MOTORES              LA CARA  /api/iceberg
     spark · duckdb · karma           (Iceberg REST gobernado)
     mlrunner · pg                              │
              │                                 │
              └────────────────┬────────────────┘
        ┌──────────────────────┴───────────────────────┐
        │  CINTURA ESTRECHA:  Iceberg + Lakekeeper     │
        └──────────────────────┬───────────────────────┘
                     Cloudflare R2 (Parquet)

Y en paralelo, el plano de control: PostgreSQL (Index) + OpenFGA + Keycloak.

⇒ El recorrido completo, salto a salto, está en mapa-computo.md §1.


3 · La cintura estrecha — Iceberg + Lakekeeper

Un único punto por el que pasa todo lo que es dato. No es un motor: es un formato (Apache Iceberg) y un catálogo (Lakekeeper, Iceberg REST).

CatálogoLakekeeper, type=rest, en Railway
FormatoApache Iceberg (metadata.json + manifiestos + Parquet)
AlmacenamientoCloudflare R2, bucket lakehouse, WEUR
Warehouses9 (1 compartido + 8 por inquilino, éstos vacíos)

Por qué esto y no un motor: un motor que aplica política sólo la aplica a sí mismo. Con la política en el catálogo vale para Spark, DuckDB, PyIceberg y lo que venga — lo que la industria llama catalog-centric.

⚠️ Y lo que Iceberg NO da: seguridad de fila y columna. Lakekeeper+OpenFGA llega hasta tabla.


4 · Las dos puertas

4·1 · Junction — la puerta del PRODUCTO

lib/compute/junction.ts. Una puerta, dos entradas (loadItemForConsumption para identidad lógica, runQuery para SQL de usuario). Resuelve catálogo lógico → binding físico → valida la tenencia TABLA POR TABLA → construye el GovernanceContextelige motor → despacha.

⭐ La elección de motor es política de la puerta, en una función con nombre: elegirMotorDeLectura(esWrite) → hoy spark siempre, o lanza.

Sin try { spark } catch { duckdb }, y sin degradación declarada. La lección de §12·9: un fallback silencioso no causa el fallo, impide leerlo.

W4 le añadió un tercer camino: CREATE TABLE sale sin writeTarget (su destino no existe todavía) y sin plan de tablas (no tiene FROM).

4·2 · La cara — la puerta de los MOTORES

app/api/iceberg/v1/[...path]. Un Iceberg REST catalog gobernado. Hace siete cosas que un catálogo normal no (las dos últimas son de hoy):

  1. Autoriza por inquilino y niega si la tabla contradice el prefijo declarado.
  2. Acuña la credencial acotada a esa tabla.
  3. Filtra los listados a lo que es del inquilino.
  4. Resuelve nombres lógicosdietsds_<uuid>, en las dos direcciones.
  5. Firma el commitcarbon.operation-id + carbon.principal en el summary.
  6. Firma con el ID DEL LEDGER — busca la operación en vuelo y usa su id, que es lo que hace la escritura reconciliable (W3·c).
  7. MATERIALIZA las tablas nuevas — al recibir createTable da de alta el dataset en Index con el esquema del body y reenvía como ds_<uuid> (W4·2).

Las cuatro últimas comparten un porqué: son cosas que el motor no puede hacer y que, hechas aquí, valen para todos los motores a la vez. Cada vez que aparece algo así, la respuesta ha sido la misma: baja al catálogo.

⚠️ Y una que aprendió hoy: «no está» ≠ «no puedes». unknown-table devuelve 404 NoSuchTableException, no 403 — sin eso, un CREATE TABLE moría invisible en el loadTable previo.


5 · Gobernanza — quién puede qué

Qué gobiernaDónde
Index (PostgreSQL)privilegios · procedencia · access_events · el ledgerRailway
OpenFGAautorización de Lakekeeper (ReBAC)Railway · ⚠️ inerte (allowall)
Keycloakidentidad de los serviciosRailway

⚠️ DOS caminos, DOS aplicadores

CaminoQuién aplica
por la cara (Spark)Index
directo a Lakekeeper (warehouse-writer, duck-server)OpenFGA

Los principals

PrincipalPuede
lakekeeper-sparkleer. ⛔ NO escribe, y es un GATE (S2/S4 lo usan de control negativo)
lakekeeper-spark-editorleer · escribir datos · CREAR tablas (desde W4). ⛔ NO TABLE_DROP
lakekeeper-spark-etlCATALOG_MANAGE_CONTENT
lakekeeper-spark-maintenancecompactar. ⛔ no suelta
lakekeeper-warehouse-writerla ingesta
lakekeeper-operatoradministrar y repartir permisos

TABLE_CREATE entró en el editor con W4, y el porqué es el mismo que lo negaba: D2 lo prohibía porque «abriría un segundo camino de creación que se saltaría el registro». Con la cara materializando, no hay segundo camino: hay uno que empieza en Index. El privilegio dejó de ser la política y pasó a ser el conducto.

TABLE_DROP sigue fuera, y no es simetría: crear de más deja una tabla que sobra —contable y borrable—; soltar de menos no deja nada.

⚠️ El límite de la gobernanza de USUARIO, medido hoy

grants por principal_kind:   360 × service      usuarios con grants: 0

principal_grants sólo está poblado para servicios. ⇒ La autorización efectiva de un usuario es la pertenencia al workspace, que es lo que la puerta aplica para leer, escribir y crear. El modelo soporta kind:'user'; la capa no existe. Es el Horizonte 2, no una fase de W. Se dice en vez de fingir una granularidad que no hay.

Censo del plano de control

datasets 109 · schemas 14 · catalogs 9 · workspaces 8 · principal_grants 360
dataset_transactions where status='open'  →  0

6 · Almacenamiento y credenciales

Cloudflare R2, bucket lakehouse. El perímetro es explícito — quién toca R2: warehouse-writer RW · duck-server RO · Lakekeeper (el 4º sitio que se olvida y rompe toda escritura) · ml-runner ninguno.

Vending, no llaves estáticas: STS 900 s, acotado, con control negativo (leer su prefijo PERMITE · leer otro inquilino DENIEGA · escribir otro DENIEGA).


7 · Cómputo — plural, y detrás de la cintura

MotorPapelEstado
sparkingesta, ETL, ML, notebooks y el SQL Editor entero🏁 sirve lectura, DML, DDL y CTAS
duckdbel DML del editorvivo como servicio; Junction ya no lo elige para nada
karmamotor propio (Rust), sólo lecturadesplegado, inerte
mlrunner · pgno hablan SQL de usuario / legacyvivos
trinodemolido 2026-08-09fuera

available es un eje separado de sql/read: un motor no desplegado no puede mentir sobre sí mismo.

Spark, y por qué hace falta un puente

Spark Connect no tiene cliente Node. La pieza que lo une es el puente: HTTP entra, gRPC sale. Y el número que manda sobre su diseño:

primera query 5.832 ms  ·  p50 en caliente 125 ms  ·  47×

Por eso es un servicio con estado cuyo trabajo real es sostener sesiones calientes (una por inquilino, LRU 8, inactividad 30 min). ⚠️ Y de ahí el límite: no escala en horizontal sin afinidad de sesión. Hoy, 1 réplica.


7·bis · ⭐⭐⭐ EL CAMBIO DE PARADIGMA — dónde estamos frente a Furnace y Rubix

Esta sección es un índice. El análisis completo, con las citas y la comparación,
está en mapa-computo.md — y es el documento que hay que leer
antes de decidir nada de cómputo.

Junction es el Furnace de Carbon, y la equivalencia es correcta en la intención y falsa en el mecanismo:

FurnaceJunction
Una puerta, motores plurales, formato común✅ (y el gobierno es más fuerte)
Parseo y planificación⭐ unificados y agnósticos de motor (Calcite → AST)no hay: se manipula TEXTO con regex
Dialectouno solo; el motor recibe un plan⛔ el del motor ⇒ common syntax no existe

⭐⭐ Y eso explica de golpe los seis fallos de dialecto de esta semanaWITH … CREATE TABLE, el destino cualificado dos veces, el CTE capturando un DELETE, el ^ que no veía el prólogo, las 2 divergencias silenciosas, INSERT … SELECT fuera—: no eran independientes, eran el mismo, seis veces.

No falta un parser: hay uno malo, en diez sitios. Y el repo ya sabe la respuestacatalog-sql/grammar.ts (M3) llevó la gramática de catálogo a datos «sustituyendo la cadena de 10 regex». El camino de datos sigue en regex. La dirección no hay que inventarla: hay que terminarla.

Y el sustrato frente a Rubix («K8s endurecido, HA, nodos que no viven más de 48 h, cada carga aislada, cada interacción autenticada y registrada»):

los CUATRO pods —driver, 2 executors y el puente— EN EL MISMO NODO
   4 vCPU · 16 GB · europe-west1-b · sin política de ciclo

No tenemos una malla: tenemos un nodo con cuatro pods. Sirve el editor entero, y está bien para donde estamos — pero conviene no confundirse al planificar.


8 · Dónde corre cada cosa

PlanoDóndeQué
AplicaciónVercelNext.js · app.paladio.io · la cara /api/iceberg
CómputoGKE trino-compute-clone (europe-west1)Spark 4.1.2 + SparkConnectServer + el puente
ServiciosRailwayLakekeeper · Keycloak · OpenFGA · Postgres · duck-server · ml-runner · warehouse-writer · karma · Jupyter
DatosCloudflare R2Parquet + metadata Iceberg

El techo real es la cuota: CPUS_ALL_REGIONS = 12, y la subida fue denegada (cuenta de trial), igual que Spot (PREEMPTIBLE_CPUS = 0).

⭐ Apagar y encender el cómputo es MODO DE OPERACIÓN NORMAL

«Spark no sirve» es casi siempre «Spark está apagado». Se comprueba PRIMERO:

kubectl -n spark get pods                 # vacío = apagado, no averiado
kubectl -n spark patch sparkconnectserver carbon-connect --type=merge \
  -p '{"spec":{"clusterOperation":{"stopped":false}}}'
kubectl -n spark scale deploy/spark-bridge --replicas=1

Medido: Spark + 2 executors listos en ~30 s; el puente en ~2 min.


9 · Lo que está PROBADO — con su gate

Los del 03 siguen vigentes (Spark arranca · lee por la cara · la autorización vive en el catálogo · vending acotado · latencia del puente · 8/8 de aislamiento · catálogo por sesión · dialecto derivado 16/25 · B8 la sesión sobrevive a su token · W0 · W1 · W2). Lo añadido hoy:

Qué se probóEvidencia
🏁W3·a·0 · la escritura, arregladaUPDATE pasa de ParseException a publicar snapshot · el destino se cualifica al dialecto del motor, no al de DuckDB
🏁W3·a · el ledger dice la verdadengine derivado · snapshot_attribution: 'none' para el no-op · el diagnóstico de unverifiable distingue sus tres causas
🏁⭐⭐⭐ W3·b · el camino del PRODUCTO escribew3-b-gate-puerta-escribe.ts salida 0 — y es el primer gate que ejerce runQuery: el de W2 medía puente→Spark→cara y se saltaba la puerta
🏁⭐⭐⭐ W3·c · la escritura es RECONCILIABLEw3-c-gate-atribucion.ts salida 0: landed=true · un uuid inventado no aterriza · ratifycommitted
🏁⭐⭐ W3·d · un reintento no escribe dos vecesw3-d-gate-idempotencia.ts salida 0 con cuatro negativos
🏁⭐⭐ W5 · la cola del ledger a CERO21 → 0. Las 12 incerrables por ratify se cerraron con evidencia forense —cero snapshots en la ventana— y la prueba escrita en el asiento
🏁⭐⭐⭐ W4 · el editor CREA TABLAS, CTAS incluidow4-gate-create-table.ts salida 0, 7 comprobaciones · dataset en Index con project_id=NULL + tabla física + el nombre lógico resuelve · dos negativos
🏁⭐⭐⭐ P·5·b·2·a (08-11) · el default de lectura, POR WORKSPACEDeja de ser variable global y pasa a ser del inquilino (como el workspace default catalog de Databricks). ⭐ No hizo falta inventar dónde vive: Index ya modela catalogs → schemas con datasets.schema_id 114/114el namespace Iceberg ES esa pareja con un punto; y el valor declarado va en workspaces.settings (JSONB ya existente, cero migraciones). ⭐ Declarado, no derivado: derivarlo («donde más tablas tiene») funcionaría hoy y cambiaría solo el día que alguien cargue 200 tablas en otro esquema. ⭐⭐ El ns viaja en el token y eso NO contradice que el prefijo se derive: el prefijo lo pueden calcular las dos partes (⇒ transportarlo crea una 2ª fuente), el namespace vive en Index y el puente no tiene BD a propósito (⇒ no hay 2ª fuente). Lo resuelve la puerta y viaja en el GovernanceContext como writeTarget: el adaptador sigue ciego. Gate p5b2-gate-namespace-por-workspace.ts salida 0: sin ns→global · con ns→lo adopta en 706 ms ⇒ sesión CALIENTE, sin recrearla · lo re-emite al cambiar · lo inválido se ignora. W3·b y P·6 verdes · 528 tests
🏁⭐⭐ P·5·b·1 (08-11) · el puente fija el NAMESPACE de sesiónsesiones.py emite USE <DEFAULT_NAMESPACE> al crear la sesión (en try: si no existe, la sesión nace igual y se dice). Desplegado por ConfigMap —el código del puente no está en una imagen— y verificado en frío (p5b-gate-namespace-de-sesion.ts salida 0; los 3.743 ms prueban que la sesión era nueva): current_schema=main.default, el nombre pelado resuelve, y la coordenada completa sigue sin regresión. ⛔⛔ PERO el lado Node queda BLOQUEADO, y por la trampa que el propio comentario del cambio advertía: Node tiene LAKEHOUSE_NAMESPACE=main.test y el puente main.default. Censo: 84 datasets en main.default · 20 con namespace NULL (→ fallback a main.test) · 9 en main.test · 1 en main.my_first_project ⇒ (a) hay 4 namespaces, así que el prólogo tiene que seguir para 30 datasets; (b) el fallback de Node no apunta a donde está el dato (misma clase de divergencia de cintura que F3 corrigió en fqn.ts); (c) los 20 NULL resuelven por ese fallback y el censo de deriva contaba 20 fantasmas — coincidencia por medir. Decisión del owner: cuál es EL namespace por defecto
🏁⭐⭐⭐ P·5·a (08-11) · la TERCERA ruta del mismo fallo — y desbloquea P·6qualifyWriteTarget devolvía el SQL intacto y en silencio cuando el usuario cualificaba el destino: rewriteDottedFromRefs ya lo había COLAPSADO (FROM a.b.cFROM c, y un DELETE FROM lleva la palabra FROM), así que el token decía main.default.t y el SQL decía t. ⇒ destino pelado junto a la CTE homónima = la única combinación que hace fallar AND col IS NOT NULL, o sea nuestra propia reescritura prohibía el rodeo seguro del corruptor de C3. Arreglo: probar las dos formas del token (entera y último segmento) y dejar de callarse cuando ninguna casa. Regresión en frío verificada en rojo · W3·b y W4 verdes · 528 tests. ⚠️ La 1ª verificación dio falso verde porque la reversión de prueba no llegó a aplicarse ⇒ una reversión tiene que confirmar que ha revertido
🏁⭐⭐⭐ P·6 (08-11) · el GUARDIÁN del DELETE — VERDEdelete-null-guard.ts. Detecta la igualdad negada porque el motor normaliza las 3 formas en una (NOT ('col = val)): sobre el SQL del usuario habrían sido 3 sintaxis. Gate p6-gate-guardian-delete.ts: ① rechaza y el dato queda intacto · ③ control con el guardián apagado: 3 → 1 filas, la NULL se pierde ⇒ el defecto es real. ⛔⛔ PERO ② destapó que el rodeo que recomienda es IMPOSIBLE por la puerta, y aislado en p6-b-diag-rodeo.ts sólo falla UNA combinación: destino PELADO + CTE HOMÓNIMA + rodeo — que es exactamente lo que planSql manda en cada escritura. ⇒ la reescritura de la puerta hace imposible la forma segura y posible la peligrosa, y la forma que genera es la del bug silencioso de la semana, que no estaba arreglado: la puerta lo produce solo. ⇒ resuelto por P·5·a (fila de arriba): el gate pasa a VERDE — ① bloquea con el dato intacto · ② el rodeo funciona y borra sólo lo que debía · ③ el control demuestra que sin guardián se pierde la fila NULL
🏁⭐⭐⭐ P·4 (08-11) · EL AUDITOR DEL PLAN, en sombra y con criterio de salidalib/compute/plan-auditor.ts + PLAN_AUDITOR=off|shadow|enforce. ⭐⭐ El problema tiene nombre en la industria: parser differential —dos parsers decidiendo sobre la misma entrada; es el mecanismo de los 1.207 bypasses de WAF de WAFFLED y de CVE-2026-52747— así que el destino no es discutible: eliminar el segundo parser (P·4·bis). Y el camino tampoco: sombra primero, como Envoy/Istio/OPA. ⭐ Con una diferencia a favor: ellos ponen en sombra la DECISIÓN, aquí se pone la ENTRADA de la decisión (qué objetos se tocan). CRITERIO_DE_SALIDA vive en el CÓDIGO (500 queries sin no-vista · 14 días · cero sin explicar), porque un shadow sin criterio se queda de shadow para siempre. En vivo: acuerdo en escritura y en CTAS, con W3·b y W4 verdes ⇒ cero regresión. 12 tests, verificados en rojo
🏁⭐⭐ P·4·0 (08-11) · sobre QUÉ SQL se preguntap4-0-gate-sobre-que-sql.ts salida 0. El approach daba por hecho «el SQL del usuario» y habría sido REGRESIÓN DE GOBERNANZA: medido, una consulta a una vista sólo enseña la vista y las tablas de dentro quedarían sin gobernar. Sobre el SQL ejecutado da la base con coordenada (el baseTables de hoy la tira). ⇒ Es lo que hace PostgreSQL (privilegios TRAS la reescritura) y Trino (en el análisis, con nombres resueltos). Y reformuló P·4: el plan AUDITA, no sustituye — el plan da nombres, entries necesita datasetId/fqn/schema
🏁⭐⭐⭐ P·3 (08-11) · el DIFERENCIAL: las dos implementaciones, comparadas ANTES de cambiar nadanpm run check:plan-diferencial, en frío, reproduciendo el camino viejo como lo compone producción. 29 comparados · 7 discrepancias, las 7 estudiadas. ⭐⭐ La familia grande (6): scanTableRefs sólo mira tras FROM/JOIN, así que en un UPDATE x el viejo no ve NINGUNA tabla — y delete-simple sí empata sólo porque DELETE FROM lleva la palabra FROMla cobertura del destino en baseTables dependía de si el verbo usa esa palabra: accidente, no diseño (M6 otra vez). La que decide (1): en cte-homonima-del-destino el viejo habría seguido adelante con una sentencia que el motor no sabe planificar. ⚠️ Y mi comparador mintió primero contra el lector (normalizaba al último segmento y hacía perder 2 de 9): 7ª vez que el instrumento falla antes que el sistema. ⚠️ Consecuencia anotada para P·4: el destino pasará a estar en referencias ⇒ hay que separar LEER de ESCRIBIR con privilegioRequerido
🏁⭐⭐⭐ P·2 (08-11) · EL LECTOR DE PLANES, y 37/37 SIN TOCAR EL CLUSTERlib/warehouse/query/plan-reader.ts: texto del plan → HechosDelPlan (verbo · destino · privilegio · referencias · funciones de tabla). ⭐ La regla de seguridad no es reconocerlo todo, sino lo que METE DATOS: lo que huela a relación/tabla/identificador y no esté en la lista blanca rechaza; lo que sólo transforma se atraviesa. Fijado en las dos direcciones por test. ⭐⭐ Y lo que el plan regala: el destino lo marca Spark (__required_write_privileges__), los nombres de CTE los declara él y sin comillas, y el prólogo WITH … UPDATE deja de ser posición en una cadena y pasa a ser un nodo del árbol. ⚠️ 37/37 a la primera era sospechoso ⇒ se rompió el lector a propósito y el suite dio 3 rojos exactos; los 9 esperados provisional se promovieron por medición
🏁⭐⭐ P·1 (08-11) · las fixtures de plan, congeladasp1-capturar-fixtures.ts: 29 planes + 2 negativas, --trinquete verde ⇒ P·2 y P·3 se harán EN FRÍO. Lo que ya demuestran: un literal es una expresión, no una relación (el fallo de M2c deja de ser representable) · 'DeleteFromTable NOT ('nombre = x)el predicado del corruptor es legible y P·6 es implementable · INSERT … SELECT da fuente y destino por separado. ⭐⭐ Tercer desenlace descubierto: EXPLAIN puede tener éxito y devolver un error de planificación como TEXTO —ni ParseException ni plan— y ahí un lector ingenuo devolvería «sin hechos» ⇒ fail-OPEN; queda como fixture negativa obligatoria. ⚠️ El trinquete falló primero por normalizar el plan y no el texto del error
🏁⭐⭐⭐ P·0 (08-11) · «que parsee el MOTOR» es VIABLEp0-gate-plan-motor.ts salida 0. EXPLAIN EXTENDED cubre los 6 verbos (SELECT·UPDATE·DELETE·MERGE·CTAS·INSERT) sin un solo efecto (estado y snapshot idénticos antes/después + el CTAS no creó tabla), falla con sintaxis rota, y nombra toda referencia exista o no. ⭐⭐ Y da más de lo que se le pedía: 'UnresolvedRelation [main, default, t] ya partido en namespace, __required_write_privileges__=UPDATE —el propio Spark declara el privilegio— y 'UnresolvedTableValuedFunction [read_parquet], que distingue nativamente el vector H5 que scanTableRefs reconoce a mano. ⚠️ Dos costes: la salida sigue siendo texto (aunque generado por máquina y versionado con el motor) y ~373 ms por query SIEMPRE, antes de autorizar
🏁⭐⭐⭐ C4+C5 (08-11) · Carbon SQL es un CONTRATO, y con dientesEl contrato pasa a ser DATO (lib/warehouse/carbon-sql-contract.ts) y la superficie se genera en fríodocs/warehouse-sql/carbon-sql-contract-v1.md, trinquete npm run check:carbon-sql-contract probado en rojo. ⭐ De ahí sale C5 sin mecanismo nuevo: una deriva entre lo declarado y lo medido ES un cambio de comportamientoc0-pinneo.ts pasa de censo a detector de deriva (salida 0: 9 interruptores + 5 sondas de comportamiento) y un guarda del bundle impide cambiar un valor 🔴 sin subirlo (forzado → exit 1). Publica lo que suele esconderse: cuántos esperados siguen provisional y qué NO cubren los corpus
🏁⭐⭐ C2 (08-11) · el corpus de LECTURA, todo el espectroc2-corpus-lectura.ts salida 0 · 114 sondas · 18 ejes · 113 SOPORTADA · 0 incomparables. Encontró que un BIGINT se corrompe en el transporte (ver §10), con su sonda de diagnóstico al lado. 4 de los 5 desacuerdos iniciales eran esperados míos — incluida una sonda que asumía ORDEN sin ORDER BY, que mide el plan y no la semántica
🏁⭐⭐⭐ C3 (08-11) · el corpus de ESCRITURA existe y encontró un CORRUPTORc3-corpus-escritura.ts salida 0 · 16 sondas · 15 SOPORTADA · 1 corruptor en línea base. ⭐ Los esperados se autorizan A MANO porque con un solo motor el completion de sqllogictest convierte «lo que Spark hace» en «lo que es correcto». El eje confianza se ganó el sitio: 3 esperados míos estaban mal y el corpus los cazó a la primera. Gate previo c3-0-gate-rollback.ts salida 0 (revierte por snapshot sin poder soltar, con el DROP negado como negativo)
🏁⭐⭐⭐ C1 (08-11) · D1 ARREGLADO — el plan emite SQL que Spark parseac1-gate-citado.ts salida 0: plan REAL contra el parser real, y el negativo lo produce el mismo código con dialect:'duckdb' (VISTA e information_schema → ParseException). El alcance fue el doble de lo anunciado: el citado se decidía en 5 sitios, uno solo sensible al dialecto ⇒ nace lib/warehouse/query/ident.ts, un citador. De regalo, dos que no estaban en el plan: scanTableRefs no sabía leer backticks y extractCteNames no reconocía CTEs citadas. 6 regresiones en las dos direcciones · 326/326 · tsc sin errores nuevos
🏁⭐⭐ B9 (08-11) · USE sustituye al prólogo de CTEsb9-use-namespace.ts salida 0. La sesión nace con current_schema() VACÍO — ésa es la causa de UnresolvedRelation, en un número. USE main.default se acepta, persiste entre peticiones HTTP (y entre corridas: la sesión caliente lo conservó minutos después) y el nombre pelado resuelve, sin regresión de la FQN. ⚠️ El gate falló dos veces antes de acertar: comparó contra 'default' cuando current_schema() devuelve el namespace ENTERO, y no era idempotente (la 2ª corrida heredaba el namespace de la 1ª y se quedaba sin negativo)

⚠️ Y lo que estos gates siguen sin cubrir

  • Ninguna suite ejecuta runQuery en modo LECTURA de extremo a extremo. Sigue abierto desde el 03.
  • El corpus de ESCRITURA nunca se midió. Es el hueco más caro (§10).
  • La ruta HTTP del editor (/api/sql-editor/execute) no se puede medir desde un script: va detrás de Clerk, que responde 404 —no 401— a quien no tiene sesión.

10 · Lo que está abierto

Bloqueantes de producto

⚠️ D2 · SELECT "a" devuelve la CADENA asin error, y no se arregla: se DECLARA (es Spark con el pinneo vigente). Lo que sí cambió: Monaco pasa de pgsql —que lo pintaba como identificador— a sql. ⚠️ Falta la definición Monarch propia de Spark SQL (va con C4)
⛔⛔ Un BIGINT pierde precisión al llegar al clienteMEDIDO el 08-11 (C2). 92233720368547758079223372036854776000; la sonda hermana en texto devuelve los dígitos exactos ⇒ el valor es correcto en Spark y se pierde al SERIALIZAR (el number de JS no llega a 2^63). No es dialecto: es transporte, y llega igual al navegador ⇒ el usuario ve un número equivocado sin error. ⭐⭐ 3ª vez que esta causa muerde (snapshot-id del write path · gate C3·0 · ahora dato de usuario). Arreglo: que el puente serialice los enteros de 64 bits como texto, que es lo que YA hace con DECIMAL
⛔⛔ DELETE borra las filas cuyo predicado es UNKNOWNMEDIDO Y CARACTERIZADO el 08-11 (c3-diag-nulos.ts). DELETE … WHERE s <> 'a' se lleva también la fila con s NULL; el mismo predicado en SELECT y en UPDATE se comporta BIEN. ⇒ CORRUPTOR real: no da error, commitea y destruye una fila que nadie pidió borrar. Rodeo: AND <col> IS NOT NULL. En la línea base de write-probes.ts; falta publicarlo (C4) y decidir si la puerta lo mitiga
⚠️⚠️ El dialecto de ESCRITURAnunca medido MEDIDO (C3, 08-11): 16 sondas, 6 ejes, 15 verdes, 1 corruptor (el de arriba). Lo que sigue sin cubrir: CREATE TABLE/CTAS sin sonda, concurrencia cero, y el oráculo de gobierno no se ejerce por sonda. Histórico: El DML pasó de DuckDB SQL a Spark SQL sin ejercer un corpus. ⭐ Es el único punto donde un fallo corrompe el dato y nadie mira. ⇒ approach entregado: carbon-sql-contract-approach.md
⚠️ ANSI de Spark 4activo (spark.sql.ansi.enabled=true); el corpus no lo cubre
rowsAffected siempre 0Spark no reporta filas afectadas en un DML por SQL. El arreglo honesto es null → «—», no inventar el número
B7 · mantenimiento Iceberg⚠️ 144 tablas sin compaction ni expiración. No llega con la escala: llega con el tiempo
B3 · resultados grandestodo va INLINE. Falta EXTERNAL_LINKS
Time travel en SQLlos snapshots ya están; falta exponerlos. Lo más barato de la lista
Vistas materializadasno existen
INSERT … SELECTfuera de los verbos. ⭐ Ya no es imposibilidad: W4·4 demostró que la puerta sabe resolver una fuente

Deuda de sustrato

⚠️ Deriva Index ↔ físico144 físicas · 109 datasets · 89 casan · 67 HUÉRFANAS · 20 FANTASMAS. De las huérfanas, 24 están en namespaces LEGACY (datasets, bench, test.*). ⚠️ Y los gates de W4 son una fuente activa: cada corrida deja una (el editor no puede DROP, a propósito). Qué hacer con ellas —adoptar o borrar— es decisión del owner
⚠️ Secretos por rotarBRIDGE_JWT_SECRET es simétrico, y desde W4 ese conducto también crea tablas
⚠️ El puente no escala en horizontalsesiones en proceso, 1 réplica
⚠️ OpenFGA inerteAUTHZ_BACKEND sigue en allowall
⚠️ Sin gobernanza por USUARIO360 grants, todos de servicio
⚠️ Clientes de Keycloak a manono están como código
⚠️ Nombres de un motor demolidoel cluster se llama trino-compute-clone
⚠️ La VM de OpenMetadatasigue en pie, y perfila vía Trino, que ya no existe

11 · ⭐ Las prioridades — por DAÑO, no por vistosidad

El desarrollo completo, con su porqué, está en
mapa-computo.md §7.

P0 · lo que YA cobra intereses
     1. corpus de ESCRITURA + ANSI   ← corrompe el dato sin que nadie mire.
                                       Y BLOQUEA la pluralidad de motores
     2. rowsAffected honesto         ← horas de trabajo, se ve cada día
     3. mantenimiento Iceberg (B7)   ← llega con el TIEMPO, no con la escala

P1 · el cambio de punto de vista
     4. consolidar el parseo del camino de DATOS  (terminar lo que M3 empezó)
     5. elegir motor por TAMAÑO      ← depende de P0·1

P2 · producto que aún no existe
     6. EXTERNAL_LINKS · time travel en SQL · vistas materializadas · Iceberg v3

P3 · sustrato  ⛔ TODO bajo el techo de CPUS_ALL_REGIONS = 12 (subida DENEGADA)
     7. malla real · un solo sitio de ejecución · rotar el secreto simétrico

⚠️ Y lo que NO está en ninguna prioridad, a propósito: «expandir Spark a toda la plataforma». La investigación dice lo contrario — Foundry pone Polars/DuckDB por defecto y Spark «sólo cuando el dato excede un nodo». Lo que se expande es la malla (P3) y la puerta (P1): que es lo que Rubix y Furnace son, cada uno en su capa.

Los horizontes, más allá

  • Horizonte 2 · la gobernanza efectiva — encender OpenFGA y decidir fila/columna. Y la capa de usuario, que hoy no existe.
  • Horizonte 3 · la proyección ontológica — CQRS a nivel de datos: el catálogo es el SoT de escritura, el grafo un read-model proyectado. La cuña frente a Stardog es que nuestro catálogo es NATIVO: ellos importan metadata, nosotros heredamos.
  • Horizonte 4 · los agentesel agente NUNCA ve las tablas. Consume el grafo proyectado. Ése es el producto: Agentic Data Cloud.

12 · Las reglas que la práctica dejó

Las 17 del 03 siguen vigentes. Lo que añadió esta jornada:

  1. ⭐⭐⭐ Cada cambio de motor deja atrás piezas que asumían el anterior — y no las caza ni el compilador ni los tests. Se repitió tres veces el mismo día: el literal engine:'duckdb' del ledger, el alias 'lake' de la cualificación, y el ancla ^ que no veía el prólogo. ⇒ Al cambiar de motor, buscar los literales del viejo (grep 'duckdb', grep "'lake'") es parte del cambio, no una limpieza.
  2. ⭐⭐⭐ Un gate que mide con «lo que le venía bien» no mide el sistema. Todos los gates de escritura pasaban el nombre pelado (ds.name); un usuario escribe UPDATE main.default.diets. La regresión llegó a producción por esa diferencia. Es §12·2 llevada al extremo: la sonda tiene que usar el vocabulario del usuario, no el que le resulta cómodo.
  3. ⭐⭐ Un censo que ve una parte no cuenta de menos: cuenta MAL. Dos veces: listNamespaces sin ?parent= devuelve sólo el primer nivel (23 de 144 tablas, y declaró fantasma una tabla viva), y medir por la cara subcuenta porque filtra al inquilino a propósito. Para censar: al catálogo directo y recorrer el árbol.
  4. ⭐⭐ Antes de construir un mecanismo, buscar si el repo ya lo tiene. La idempotencia del DML no se construyó: ya existía (lib/sdk/idempotency.ts, el contrato de Stripe completo). Montar el segundo habría sido dos sitios donde decidir lo mismo.
  5. ⭐⭐ Un aborted sin su prueba al lado es lo que la regla prohíbe; con la prueba, es reconciliación. Las 12 transacciones que ratify no podía cerrar se cerraron demostrando algo más fuerte que lo que se buscaba: «la tabla no publicó un solo snapshot en toda la ventana». ⇒ Cuando la pregunta no tiene respuesta, buscar otra pregunta que sí la tenga — y escribir la evidencia en el propio asiento.
  6. La idempotencia sólo funciona si la clave la pone quien sabe que dos peticiones son la misma operación, y ése es el cliente. Una clave nueva por ejecución —el primer intento— no deduplica nada. Y sólo se cachea el éxito: cachear un fallo vuelve permanente un error transitorio.
  7. Una migración se auto-verifica, y con su negativo. DROP CONSTRAINT IF EXISTS sobre un nombre que no casa no falla y no hace nada. Se comprueba el estado: que lo nuevo pasa y que lo inválido sigue rechazado.
  8. ⭐⭐⭐ El fallo estructural de esta semana fue tratar el SQL como TEXTO. Seis bugs distintos, una sola causa. ⇒ Cuando varios fallos comparten forma, no son varios fallos.

13 · Los documentos que importan

DocPara qué
estela foto completa. Manda sobre todo lo demás
⭐⭐ mapa-computo.mdel cambio de paradigma: el pipeline entero, Furnace, Rubix, la paridad y las prioridades. Leer antes de decidir nada de cómputo
⭐⭐⭐ parser-plan-motor-approach.mdel parser: que parsee el MOTOR (opción C). Por qué no un parser propio (la industria, y Vitess como precedente de proxy) · el plan PARSED y no el ANALYZED, que es lo que no invierte el orden de la autorización y hace la caché global · la distinción RECONOCER vs REESCRIBIR · fases P·0-P·7
⭐⭐ carbon-sql-contract-approach.mdel contrato de dialecto + el corpus, fundidos (A + P0·1). Trae el pinneo medido de qué ES Carbon SQL, los defectos D1/D2, y el diseño del corpus de escritura (doble oráculo · clase CORRUPTOR · reversión por snapshot)
w-write-path-approach.mdel camino de escritura W0-W5
w3-ledger-idempotencia.mdW3 y W5 · el censo del ledger y la reconciliación forense
w4-create-table.mdW4 · el CREATE TABLE y la materialización por la cara
b-spark-bridge-approach.mdel puente · ⚠️ §B4 sustituido
sustrato-03.md · 02 · 01congelados
platform-thesis.mdel norte: el cliente compra contexto
⭐⭐ federated-catalog-wedge.mdREORIENTADO el 2026-08-11 a FULL COPY. La federación sale del norte para siempre; la cuña sigue en pie y se ensancha: se COPIA en vez de federar, porque el problema es de gobernanza, no de acceso — y sólo copiando se entra en la cadena de custodia de la que el grafo hereda permisos, linaje y política. ⚠️ Deja 4 costes declarados (frescura · conector por fuente · almacenamiento+B7 · borrado/GDPR) y una deuda medida: la oferta de fuentes se acotó «a lo que Trino federa» (6 deprecadas) y ese criterio caducó con el motor

Los gates que se corren, y contra qué

npx dotenv -e .env.local -- npx tsx scripts/governance/w3-b-gate-puerta-escribe.ts   # ¿escribe el camino del producto?
npx dotenv -e .env.local -- npx tsx scripts/governance/w3-c-gate-atribucion.ts       # ¿es reconciliable?
npx dotenv -e .env.local -- npx tsx scripts/governance/w3-d-gate-idempotencia.ts     # ¿deduplica un reintento?
npx dotenv -e .env.local -- npx tsx scripts/governance/w4-gate-create-table.ts       # ¿se puede crear una tabla?
npx dotenv -e .env.local -- npx tsx scripts/governance/w4-0-gate-no-esta.ts          # 200 / 404 / 403
npx dotenv -e .env.local -- npx tsx scripts/governance/w5-0-reconocimiento.ts        # la cola del ledger
npx dotenv -e .env.local -- npx tsx scripts/governance/w5-c-censo-deriva.ts          # Index ↔ físico
npx dotenv -e .env.local -- npx tsx scripts/governance/s5-0-principals-gate.ts       # la forma de cada principal
npx tsx scripts/bridge/b6-gate-motor.ts <origen>                                     # ¿qué motor sirve?