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 unsustrato-NNdebe 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) ysustrato-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:
- 🏁 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.- 🏁 La cola del ledger está a CERO (eran 21), y lo incerrable se cerró con
evidencia forense, no con un supuesto.- ⭐⭐⭐ 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.- ⭐ 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:
- La cintura estrecha es el CATÁLOGO, no un motor. El estándar no es «rápido», es «gobernado y ontologicamente PROYECTABLE».
- 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»).
- 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álogo | Lakekeeper, type=rest, en Railway |
| Formato | Apache Iceberg (metadata.json + manifiestos + Parquet) |
| Almacenamiento | Cloudflare R2, bucket lakehouse, WEUR |
| Warehouses | 9 (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 GovernanceContext →
elige 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):
- Autoriza por inquilino y niega si la tabla contradice el prefijo declarado.
- Acuña la credencial acotada a esa tabla.
- Filtra los listados a lo que es del inquilino.
- Resuelve nombres lógicos —
diets↔ds_<uuid>, en las dos direcciones. - Firma el commit —
carbon.operation-id+carbon.principalen el summary. - ⭐ 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).
- ⭐ MATERIALIZA las tablas nuevas — al recibir
createTableda de alta el dataset en Index con el esquema del body y reenvía comods_<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é gobierna | Dónde | |
|---|---|---|
| ⭐ Index (PostgreSQL) | privilegios · procedencia · access_events · el ledger | Railway |
| OpenFGA | autorización de Lakekeeper (ReBAC) | Railway · ⚠️ inerte (allowall) |
| Keycloak | identidad de los servicios | Railway |
⚠️ DOS caminos, DOS aplicadores
| Camino | Quién aplica |
|---|---|
| por la cara (Spark) | Index |
directo a Lakekeeper (warehouse-writer, duck-server) | OpenFGA |
Los principals
| Principal | Puede |
|---|---|
lakekeeper-spark | leer. ⛔ NO escribe, y es un GATE (S2/S4 lo usan de control negativo) |
⭐ lakekeeper-spark-editor | leer · escribir datos · CREAR tablas (desde W4). ⛔ NO TABLE_DROP |
lakekeeper-spark-etl | CATALOG_MANAGE_CONTENT |
lakekeeper-spark-maintenance | compactar. ⛔ no suelta |
lakekeeper-warehouse-writer | la ingesta |
lakekeeper-operator | administrar 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
| Motor | Papel | Estado |
|---|---|---|
| ⭐ spark | ingesta, ETL, ML, notebooks y el SQL Editor entero | 🏁 sirve lectura, DML, DDL y CTAS |
| duckdb | vivo como servicio; Junction ya no lo elige para nada | |
| karma | motor propio (Rust), sólo lectura | desplegado, inerte |
| mlrunner · pg | no hablan SQL de usuario / legacy | vivos |
| demolido 2026-08-09 | fuera |
⭐ 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á enmapa-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:
| Furnace | Junction | |
|---|---|---|
| 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 |
| Dialecto | uno 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 semana —WITH … 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
respuesta — catalog-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
| Plano | Dónde | Qué |
|---|---|---|
| Aplicación | Vercel | Next.js · app.paladio.io · la cara /api/iceberg |
| Cómputo | GKE trino-compute-clone (europe-west1) | Spark 4.1.2 + SparkConnectServer + el puente |
| Servicios | Railway | Lakekeeper · Keycloak · OpenFGA · Postgres · duck-server · ml-runner · warehouse-writer · karma · Jupyter |
| Datos | Cloudflare R2 | Parquet + 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, arreglada | UPDATE pasa de ParseException a publicar snapshot · el destino se cualifica al dialecto del motor, no al de DuckDB |
| 🏁 | ⭐ W3·a · el ledger dice la verdad | engine derivado · snapshot_attribution: 'none' para el no-op · el diagnóstico de unverifiable distingue sus tres causas |
| 🏁 | ⭐⭐⭐ W3·b · el camino del PRODUCTO escribe | w3-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 RECONCILIABLE | w3-c-gate-atribucion.ts salida 0: landed=true · un uuid inventado no aterriza · ratify → committed |
| 🏁 | ⭐⭐ W3·d · un reintento no escribe dos veces | w3-d-gate-idempotencia.ts salida 0 con cuatro negativos |
| 🏁 | ⭐⭐ W5 · la cola del ledger a CERO | 21 → 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 incluido | w4-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 WORKSPACE | Deja 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/114 ⇒ el 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ón | sesiones.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·6 | qualifyWriteTarget devolvía el SQL intacto y en silencio cuando el usuario cualificaba el destino: rewriteDottedFromRefs ya lo había COLAPSADO (FROM a.b.c→FROM 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 — VERDE | delete-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 salida | lib/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 pregunta | p4-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 nada | npm 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 FROM ⇒ la 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 CLUSTER | lib/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, congeladas | p1-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 VIABLE | p0-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 dientes | El contrato pasa a ser DATO (lib/warehouse/carbon-sql-contract.ts) y la superficie se genera en frío → docs/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 comportamiento ⇒ c0-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 espectro | c2-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 CORRUPTOR | c3-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 parsea | c1-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 CTEs | b9-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
runQueryen 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 a | sin 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 cliente | MEDIDO el 08-11 (C2). 9223372036854775807 → 9223372036854776000; 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 UNKNOWN | MEDIDO 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 ESCRITURA | 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 4 | activo (spark.sql.ansi.enabled=true); el corpus no lo cubre |
rowsAffected siempre 0 | Spark 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 grandes | todo va INLINE. Falta EXTERNAL_LINKS |
| Time travel en SQL | los snapshots ya están; falta exponerlos. Lo más barato de la lista |
| Vistas materializadas | no existen |
INSERT … SELECT | fuera de los verbos. ⭐ Ya no es imposibilidad: W4·4 demostró que la puerta sabe resolver una fuente |
Deuda de sustrato
| ⚠️ Deriva Index ↔ físico | 144 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 rotar | BRIDGE_JWT_SECRET es simétrico, y desde W4 ese conducto también crea tablas |
| ⚠️ El puente no escala en horizontal | sesiones en proceso, 1 réplica |
| ⚠️ OpenFGA inerte | AUTHZ_BACKEND sigue en allowall |
| ⚠️ Sin gobernanza por USUARIO | 360 grants, todos de servicio |
| ⚠️ Clientes de Keycloak a mano | no están como código |
| ⚠️ Nombres de un motor demolido | el cluster se llama trino-compute-clone |
| ⚠️ La VM de OpenMetadata | sigue 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 agentes — el 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:
- ⭐⭐⭐ 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. - ⭐⭐⭐ 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 escribeUPDATE 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. - ⭐⭐ Un censo que ve una parte no cuenta de menos: cuenta MAL. Dos veces:
listNamespacessin?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. - ⭐⭐ 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. - ⭐⭐ Un
abortedsin su prueba al lado es lo que la regla prohíbe; con la prueba, es reconciliación. Las 12 transacciones queratifyno 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. - ⭐ 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.
- ⭐ Una migración se auto-verifica, y con su negativo.
DROP CONSTRAINT IF EXISTSsobre 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. - ⭐⭐⭐ 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
| Doc | Para qué |
|---|---|
| ⭐ este | la foto completa. Manda sobre todo lo demás |
⭐⭐ mapa-computo.md | el 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.md | el 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.md | el 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.md | el camino de escritura W0-W5 |
w3-ledger-idempotencia.md | W3 y W5 · el censo del ledger y la reconciliación forense |
w4-create-table.md | W4 · el CREATE TABLE y la materialización por la cara |
b-spark-bridge-approach.md | el puente · ⚠️ §B4 sustituido |
sustrato-03.md · 02 · 01 | congelados |
platform-thesis.md | el norte: el cliente compra contexto |
⭐⭐ federated-catalog-wedge.md | REORIENTADO 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?