Published

Sustrato · 03 — 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 · 03 — la arquitectura entera, de una pieza

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

A diferencia de los dos anteriores —que eran diarios de una jornada— éste es la
foto COMPLETA
: qué piezas hay, dónde corren, cómo encajan, qué está probado y
qué no. Si sólo se lee un documento de arquitectura, que sea éste.

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 (§12).

Manda sobre cualquier approach o runbook donde discrepen.


⭐ Lo que cambió el 2026-08-10, en tres líneas:

  1. El SQL Editor sale entero por Spark — lee y escribe. DuckDB ya no es destino
    de nada por la puerta.
  2. La cara hace dos cosas nuevas que el motor no puede: resuelve nombres
    lógicos
    y firma el commit. Las dos valen para todos los motores a la vez.
  3. La escritura del editor es ATRIBUIBLE: carbon.operation-id viaja dentro del
    mismo commit. Lo que falta es cerrar el ledger (W3).

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í se derivan 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 PROYECTABLE».
  2. El cómputo es PLURAL y desechable. Los motores entran y salen por un registro; ninguno es la arquitectura.
  3. La gobernanza emana del catálogo y el grafo la heredará por relación — no se re-declara en cada capa.

2 · El mapa, de arriba abajo

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

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


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, región WEUR
Warehouses9 (1 compartido lakehouse + 8 por inquilino)
Principals conocidos por Lakekeeper8

Por qué esto y no un motor: porque un motor que aplica política sólo la aplica a sí mismo. Lo que se configura en Trino no vale cuando la misma tabla se consulta desde Spark. Con la política en el catálogo, vale para Spark, DuckDB, PyIceberg y lo que venga — que es lo que la industria llama catalog-centric.

⚠️ Y lo que Iceberg NO da: seguridad de fila y columna. Lakekeeper+OpenFGA llega hasta tabla. Para fila/columna hará falta otra pieza (vistas gobernadas, Ranger, o esperar al catálogo).


4 · Las dos puertas

4·1 · Junction — la puerta del PRODUCTO

lib/compute/junction.ts. Una puerta, dos entradas:

EntradaPara qué
loadItemForConsumptionleer un ítem por identidad lógica (building blocks)
runQuerySQL de usuario (SQL Editor, dashboards, jupyter)

Lo que hace, en orden: resuelve el 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)   //  read Y write → spark, O LANZA

DuckDB ya no es destino de nada por la puerta. Sale de la lectura el 08-10 (B6) y de la escritura ese mismo día (W2).

Sin try { spark } catch { duckdb }. Un fallback por excepción convierte «el motor está caído» en «la query salió con otro dialecto», en silencio.

⭐⭐ Y desde el 2026-08-10, tampoco hay degradación DECLARADA. La regla «si Spark no está cableado, DuckDB» se retiró: no causaba fallos, impedía verlos. Con el puente vivo y 8/8 contra el dominio público, el editor llevaba días sirviendo DuckDB y no había error que mirar, porque una lectura servida por el motor equivocado devuelve filas correctas. Hoy, sin puente, la lectura no se sirve y el error nombra la variable que falta. El precio se paga a propósito: preferimos el editor caído y ruidoso al editor sano y mentiroso.

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

app/api/iceberg/v1/[...path]. Un Iceberg REST catalog gobernado: los motores creen que hablan con un catálogo estándar, y por debajo la cara autoriza, traduce el prefijo de inquilino, acuña credenciales y anota el acceso.

Motor → /api/iceberg → Index autoriza → Lakekeeper → STS → R2

Cinco cosas que hace y que un catálogo normal no (las dos últimas son del 08-10):

  1. Autoriza por inquilino y niega si la tabla contradice el prefijo declarado — la segunda línea de defensa que sujeta el aislamiento aunque el cliente mienta.
  2. Acuña la credencial acotada a esa tabla (vendCredential), porque es el único sitio que sabe a la vez quién pregunta, qué tabla es y si tiene derecho al DATO.
  3. Filtra los listados a lo que es del inquilino: autorizar y devolver el listado entero sería gobernar el acceso y regalar el mapa.
  4. ⭐⭐ RESUELVE NOMBRES LÓGICOSdietsds_<uuid>, en las dos direcciones. Es el patrón de Unity Catalog: el usuario nombra, el catálogo identifica.
  5. ⭐⭐ FIRMA EL COMMIT — inyecta carbon.operation-id y carbon.principal en el summary del snapshot que viaja en updateTable.

Las dos últimas comparten un porqué, y es el que ordena la arquitectura: son cosas que el motor no puede hacer —Spark no sabe traducir nombres lógicos ni firmar desde SQL— y que hechas aquí valen para todos los motores a la vez, sin tocar ninguno. Cada vez que aparece algo así, la respuesta ha sido la misma: baja al catálogo.


5 · Gobernanza — quién puede qué

Los tres almacenes, y no son intercambiables

Qué gobiernaDónde
Index (PostgreSQL)privilegios por dataset/schema/catalog/workspace · procedencia · access_eventsRailway
OpenFGAautorización de Lakekeeper (ReBAC) — 398 tuplas, modelo v4.0Railway
Keycloakidentidad de los servicios (realm lakehouse)Railway

Index es nuestro Unity Catalog, y su invariante es «guarda lo que el catálogo no puede saber».

⚠️ DOS caminos, DOS aplicadores

CaminoQuién aplica
por la cara (Spark, y cualquier motor que entre por ahí)Index
directo a Lakekeeper (warehouse-writer, duck-server)OpenFGA

⚠️ Medido el 08-10: lakekeeper-duck-server no tiene NI UN evento en access_events. DuckDB nunca pasó por la cara — y aun así escribía. Por eso migrar el DML del editor a Spark no fue «cambiar de motor»: fue mover el punto de aplicación de la política de OpenFGA a Index.

Los principals, y cada uno con su rol

PrincipalRolPuede
lakekeeper-sparkquery-enginesleer. ⛔ NO escribe, y es un GATE (s5-0): S2/S4 lo usan como control negativo
lakekeeper-spark-editorsql-editor = reader + dml-writerleer y escribir DATOS. ⛔ NO TABLE_DROP ni TABLE_CREATE
lakekeeper-spark-etletl-enginesCATALOG_MANAGE_CONTENT (implica crear y soltar)
lakekeeper-spark-maintenancemaintenancecompactar. ⛔ no puede soltar
lakekeeper-warehouse-writerwarehouse-writerla ingesta
lakekeeper-operatoroperatorsadministrar, y repartir permisos

Identidad propia por superficie, y no es burocracia: el privilegio no distingue compactar de borrar, pero la identidad sí. Un access_event de mantenimiento y un DELETE de usuario no pueden parecer lo mismo. Por eso el editor no reusa -maintenance aunque su conjunto de privilegios sea casi idéntico.

⇒ La plantilla vive en lib/governance/tenant-template.ts; se aplica con reseed-tenant-grants.ts y la verifica s5-0-principals-gate.ts.

Decisión (2026-08-09): la cara es un PROXY DE CONFIANZA. Index es la autoridad para lo que entra por la puerta; Lakekeeper confía en ella. Se descartó identity passthrough porque obliga a duplicar la política en dos almacenes sincronizados a mano.

⚠️ El client_id de Keycloak ES el principal_id. Prestar una credencial no es un atajo: es falsificar la auditoría.

Censo del plano de control

datasets 109 · schemas 14 · catalogs 9 · workspaces 8 · principal_grants 288

6 · Almacenamiento y credenciales

Cloudflare R2, bucket lakehouse. Y el perímetro es explícito — quién toca R2:

ComponenteAcceso
warehouse-writerRW
duck-serverRO
Lakekeeper⚠️ el 4º sitio que se olvida y rompe toda escritura
ml-runnerninguno

⭐ Vending: credenciales temporales, no llaves estáticas

Los 9 warehouses tienen STS encendido (sts-token-validity-seconds=900, credential-type=cloudflare-r2). Lo que se acuña está acotado, y hay control negativo:

(1) leer SU prefijo         ->  PERMITE
(2) leer OTRO inquilino     ->  DENIEGA
(3) escribir OTRO inquilino ->  DENIEGA

Por qué vending y no remote signing: el firmado remoto pone al catálogo en el camino de cada petición S3. Con N ejecutores de Spark, es el cuello de botella.


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

El registro de motores

lib/compute/engines/ — un Record<Engine, EngineAdapter> total: añadir un motor sin registrar su adaptador rompe en tsc, no en producción.

MotorPapelEstado
sparkel motor de la plataforma: ingesta, ETL, ML, notebooks y el SQL Editor, leyendo Y escribiendo🏁 sirve el editor entero (2026-08-10)
duckdbel DML del editorfuera de la puertavivo como servicio; Junction ya no lo elige para nada
karmamotor propio (Rust), sólo lecturadesplegado, inerte
mlrunnerno habla SQL de usuariovivo
pglegacyvivo
trinodemolido 2026-08-09fuera

Y no hay fallback a DuckDB, tampoco en la escritura — ahí la razón es más fuerte que en la lectura: DuckDB no pasa por la cara (cero eventos en access_events), así que su commit no se firma. Degradar no daría «el mismo dato por otro camino»: daría dato sin procedencia, en silencio.

available es un eje SEPARADO de sql/read. Un motor no desplegado no puede mentir sobre sí mismo — la lección que dejó Trino anunciándose disponible sobre un coordinador inalcanzable.

Spark, y por qué hace falta un puente

Spark Connect no tiene cliente Node (oficiales: PySpark y Scala) y el editor es Next.js sobre Vercel. La pieza que lo une es el puente: HTTP entra, gRPC sale.

Vercel ──HTTPS+JWT──▶ sql.paladio.io ──▶ spark-bridge ──gRPC──▶ SparkConnectServer
                                              └──▶ la cara ──▶ Lakekeeper ──▶ R2

⭐⭐ El número que manda sobre el diseño del puente:

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

Por eso el puente es un servicio CON ESTADO cuyo trabajo real es sostener sesiones calientes — no traducir, que es lo trivial. Una sesión por inquilino, LRU con tope 8 e inactividad 30 min.

⚠️ Y de ahí sale un límite: las sesiones son estado en proceso ⇒ el puente no escala horizontalmente sin afinidad de sesión. Hoy va con 1 réplica.


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 · Carbon Jobs
DatosCloudflare R2Parquet + metadata Iceberg

El cluster, medido

1 nodo n2-standard-4 · autoscaling 1-3 · europe-west1-b
operadores Stackable 26.7.0: commons · secret · listener · spark-k8s
namespace spark: carbon-connect-server-0 · 2 executors · spark-bridge

El techo real es la cuota: CPUS_ALL_REGIONS = 12, y la subida fue denegada (cuenta de trial), igual que Spot (PREEMPTIBLE_CPUS = 0). No es maxNodeCount ni la cuota regional de N2: son 12 vCPU y punto.

Exposición

NombreIPQué sirve
app.paladio.ioVercella aplicación y la cara
sql.paladio.io8.232.149.8el puente (cert de Google Trust Services)

9 · Lo que está PROBADO — con su gate

Qué se probóEvidencia
🏁Spark arrancaPi is roughly 3.1421…
🏁Spark lee por la cara5 gates, del SHOW NAMESPACES al SELECT *
🏁La autorización vive en el catálogo① con grant LECTURA_OK ② sin grant DENEGADO ③ restaurado LECTURA_OK
🏁Vending acotado3 sondas, dos negativas
🏁La escritura gobernada existesnapshot firmado con carbon.operation-id dentro del mismo commit + control negativo sin firma
🏁Latencia del puentesuelo 145 ms p50 · 47× en frío
🏁El puente sirve SQL6/6
🏁Identidad y aislamiento8/8 contra el dominio público, y quien deniega es la cara
🏁Catálogo por sesiónla sesión B no ve el catálogo de A ⇒ 1 servidor, N inquilinos
🏁Dialecto derivado16/25 · 7 ruidosas · 2 silenciosas, cortadas antes del motor
🏁El editor ELIGE Spark (08-10)b6-gate-motor.ts · salida 0 · control negativo contra un despliegue viejo: salida 1
🏁⭐⭐ Spark SIRVE datos reales por el puente (08-10)SELECT * FROM main.default.ds_6bfc4167… LIMIT 10SUCCEEDED, 5 filas tipadas · SELECT 1 en 493 ms
🏁⭐⭐⭐ El catálogo entiende NOMBRES LÓGICOS (08-10)b6-gate-nombres.tsSHOW TABLES contiene diets (78 tablas) · SELECT * FROM main.default.diets → 5 col · 5 filas · 2 negativos verdes
🏁⭐⭐⭐ B8 · la sesión SOBREVIVE a su token (08-10)b8-gate-sesion-larga.ts → query · espera 360 s · query → 5 filas, y recreaciones_por_auth: 0 → 0 ⇒ renovó el AuthManager, no la red
🏁W0 · el editor tiene identidad propia y NO puede soltar tablas (08-10)4/4: la lectura sigue · ALTER deja de dar Forbidden · DROP → ForbiddenException · otro inquilino → denegado
🏁⭐⭐ W1 · las tablas admiten escritura (08-10)barrido: 0 sort orders huérfanos en 119 tablas · historial intacto (11 y 3 snapshots originales)
🏁⭐⭐⭐ W2 · la escritura del editor va FIRMADA (08-10)w2-gate-escritura-firmada.ts → el DML deja carbon.operation-id + carbon.principal en el summary · negativo: una lectura no firma
🏁⭐⭐⭐ W3·a·0 + W3·a · el camino del PRODUCTO escribe, y el ledger dice la verdad (08-10)w3-b-gate-puerta-escribe.ts salida 0: UPDATE por la puerta 6 → 7 snapshots (antes ParseException) · el no-op no publica y se marca attribution=none · el ledger declara engine=spark · write_target en la coordenada del motor. ⚠️ Y es el primer gate que ejerce runQuery: el de W2 mide puente→Spark→cara y se salta la puerta
🏁⭐⭐⭐ W3·c · la escritura del editor es RECONCILIABLE (08-10, desplegado)w3-c-gate-atribucion.ts salida 0 contra producción: el catálogo reconoce el txn.id como carbon.operation-id (landed=true) · un uuid inventado no aterriza · y ratify la da committed. ⇒ si la puerta muere tras commitear, la transacción se puede cerrar en vez de quedarse open para siempre
🏁⭐⭐ W5 · la cola del ledger a CERO (08-10)21 → 0. Y las 12 que ratify no podía cerrar se cerraron por evidencia forense —cero snapshots en la ventana— con la prueba escrita en el propio asiento (metadata.reconciliation). Un aborted sin su prueba al lado es lo que la regla de ratify prohíbe; con la prueba, es reconciliación
🏁⭐⭐ W3·d · un reintento del DML no escribe dos veces (08-10, desplegado)w3-d-gate-idempotencia.ts salida 0 con cuatro negativos (otra sentencia → conflict; otra clave, otro workspace → miss). ⭐ Se REUSÓ sdk_idempotency_keys —el contrato de Stripe ya estaba implementado— en vez de montar un segundo mecanismo. La clave la pone el cliente y sobrevive al reintento: una nueva por ejecución no deduplicaría nada
🏁⭐⭐⭐ W4 · el editor CREA TABLAS, CTAS incluido (08-10, desplegado)w4-gate-create-table.ts salida 0, 7 comprobaciones: la puerta acepta el verbo · el dataset nace en Index con su esquema y project_id=NULL · la tabla física existe como ds_<uuid> · el nombre lógico resuelve · CTAS con la fuente sin cualificar hereda el esquema del SELECT · y los dos negativos: crear dos veces → ALREADY_EXISTS, CREATE OR REPLACE → prohibido. ⭐ La cara MATERIALIZA: el dataset nace en Index ANTES que la tabla física, con compensación si el catálogo rechaza

⚠️ Y lo que estos gates NO cubren

Ninguna suite ejecuta runQuery en modo LECTURA de extremo a extremo. runquery-governance y runquery-write cubren gobernanza y escritura; el camino de lectura sólo se prueba en la ELECCIÓN (b6-seam), no en la ejecución. Ése es exactamente el hueco por el que nadie vio durante días qué motor servía las lecturas — y sigue abierto.

⭐ Las tres invariantes que estos gates sostienen

  1. Revocar un grant en Index corta a CUALQUIER motor — es una propiedad del sustrato, no de un motor.
  2. El dato y su procedencia no se pueden desincronizar — la firma viaja en el mismo commit.
  3. El aislamiento no descansa en el componente nuevo — la cara comprueba que la tabla no contradiga el prefijo, aunque al puente lo engañen.

10 · Lo que está abierto

Bloqueantes de producto

B3 · resultados grandeshoy todo va INLINE. Falta EXTERNAL_LINKS a R2 con URLs prefirmadas
W5 · la cola del ledger🏁 CERRADA el 08-10: dataset_transactions where status='open' → 0 (eran 21). 9 por ratify --apply en 3 pasadas (8 aborted + 1 committed) y 12 por reconciliación FORENSE: ratify no podía cerrarlas nunca —su commit de DuckDB no lleva marca— pero sí se pudo demostrar algo más fuerte: la tabla no publicó UN SOLO snapshot en toda la ventana de esas transacciones ⇒ ninguna aterrizó. Ver w3-ledger-idempotencia.md
⚠️ INSERT … SELECT sigue fuerade los verbos del editor, y ahora por menos razón que antes: W4·4 demostró que la puerta sí sabe resolver una fuente (planifica el cuerpo del CTAS). Queda como deuda acotada, no como imposibilidad
B7 · mantenimiento Iceberg⚠️ 144 tablas sin compaction ni expiración de snapshots. No llega con la escala: llega con el tiempo
ANSI de Spark 4spark.sql.ansi.enabled=true confirmado en vivo; el corpus de sondas no lo cubre
⚠️ El dialecto de ESCRITURAel DML pasó de DuckDB SQL a Spark SQL sin medir el corpus. La lectura sí se midió (16/25); la escritura no

B4 · el brazo de escritura del puentecerrado el 08-10 (W0+W1+W2). El DML del editor sale por Spark, firmado.

Deuda de sustrato

⚠️ Deriva Index ↔ físicoRe-medida el 08-10 contra el catálogo entero (W5·c): 144 físicas · 109 datasets · 89 casan · 55 HUÉRFANAS · 20 FANTASMAS. ⚠️ Las «32» anteriores eran un subconteo: se midió por la cara, que filtra a lo del inquilino, y sólo se vio main.* (30 en main.test + 1 en main.default). De las 55, 24 están en namespaces LEGACY (datasets 12, bench 11, test.* 2) que no son el Warehouse de producto. Y los 20 fantasmas son casi todos artefactos: 12 Transform path/canarios, 4 con status='error'animals, caretakers, customers, enclosure_zones, que son exactamente los datasets de 4 de las 5 txn sync.full que ratify abortó: dos mediciones independientes cuentan la misma historia—. ⇒ Qué hacer con cada grupo es decisión del owner; el censo (w5-c-censo-deriva.ts) no propone
⚠️ La causa de los sort orders sigue vivaW1 reparó las 10, pero drop-legacy-identity-columns.ts volvería a producirlas: debe cambiar el sort order antes de soltar la columna. Y falta barrer los otros 7 inquilinos
⚠️ Nombres duplicadosdos datasets animals en el mismo workspace ⇒ el nombre pelado resuelve uno en silencio. ⚠️ Ahora importa más: la cara resuelve por nombre lógico
⚠️ Clientes de Keycloak a manolakekeeper-spark, -etl, -editor no están como código. El día que se recree el realm, los gates dejan de pasar y nadie sabrá por qué
⚠️ Secretos por rotarBRIDGE_JWT_SECRET es simétrico: quien lo tenga acuña tokens para cualquier inquilino. Y carbon-r2-vending-spike tiene Admin R&W sobre todos los buckets
⚠️ /metrics sin gobernarel RESTMetricsReporter publica en cada escaneo y la cara devuelve 404: ruido + telemetría de escaneo que se tira
⚠️ El puente no escala en horizontalsesiones en proceso
🏁 B8 · la sesión muere a los 5 minutosCERRADO el 08-10 con el AuthManager pluggable + auto-recuperación. Ver el bloque de abajo: la causa era un bug de Iceberg cerrado como «not planned»
⚠️ OpenFGA inertedesplegado, pero AUTHZ_BACKEND sigue en allowall
⚠️ La VM de OpenMetadatasigue en pie, y perfila vía Trino, que ya no existe
⚠️ Nombres de un motor demolidoel cluster se llama trino-compute-clone

🏁 B8 · la sesión de Spark ya NO muere a los 5 minutos — CERRADO el 08-10

Lo que pasaba: toda operación de una sesión con unos minutos de vida devolvía NotAuthorizedException —incluido SHOW NAMESPACES— y no se recuperaba sola.

La causa, entera: el token del catálogo dura expires_in: 300; el cliente Iceberg lo refresca por token-exchange, y Keycloak lo rechaza («Standard token exchange is not enabled for the requested client») ⇒ se queda con el token muerto ⇒ 401 en todo. Y la sesión sobrevivía al token porque el LRU sólo mira inactividad, no validez.

⭐ No era un fallo nuestro: es apache/iceberg#12363, este mismo stack, cerrado como «not planned»«a similar setup on Trino works correctly». Y el OAuth2 nativo de Iceberg está deprecado (Java 1.6.0) y se elimina en Iceberg 2.0.

El arreglo, en dos piezas independientes a propósito:

Qué curaDónde
AuthManager pluggable (Dremio, API de Iceberg ≥1.9)la causa: refresca en segundo plano--packages …authmgr-oauth2-runtime:1.1.1 + rest.auth.* en sesiones.py
auto-recuperación del poolla CLASE: sesión inservible, venga de donde vengaPoolSesiones.invalidar() + reintento único

⚠️ token-refresh.idle-timeout va alineado con INACTIVIDAD_S: su defecto es PT30S y las sesiones viven ociosas hasta 30 min. Dos relojes para la misma sesión es como se cuelan los fallos que «sólo pasan a veces».

⛔ Descartados: habilitar token-exchange en Keycloak (parchea lo que Iceberg retira) y subir el TTL (5 min no es el problema — OAuth2 quiere tokens cortos porque el cliente renueva).

💶 Coste (precios de lista, europe-west1)

Mes
nodo n2-standard-4 + disco$161
tarifa de cluster GKE (regional)$73
LB del puente + NAT + IP + KMS~$25
régimen actual (1-2 nodos)~$260-420

⭐ Las dos palancas sin usar: el cluster es REGIONAL con un node pool de UNA zona —paga tarifa regional sin beneficio, y el free tier cubre los zonales (−$73/mes)— y Spot (−77 % en nodos), hoy denegado por la cuenta de trial.

⚠️ Suspender ahorra menos de lo que parece: el suelo fijo (~$97) no se suspende y minNodeCount=1 deja un nodo encendido. Con min=0, 12 h/día apagado baja a ~$258. El scale-down SÍ funciona: medido, 800 s.

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

El cómputo se apaga a diario para ahorrar, y eso significa que «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 get sparkconnectserver carbon-connect -o jsonpath='{.spec.clusterOperation}'

Encender (el interruptor del CR, no kubectl scale — el operador lo revertiría):

kubectl -n spark patch sparkconnectserver carbon-connect --type=merge \
  -p '{"spec":{"clusterOperation":{"stopped":false}}}'
kubectl -n spark scale deploy/spark-bridge --replicas=1

Medido el 08-10: Spark Connect + 2 executors listos en ~30 s; el puente en ~2 min (la readiness probe es lo lento). Apagar = lo mismo con stopped:true y --replicas=0.


11 · Los horizontes

Horizonte 1 · cerrar el motor (semanas)

Spark ya sirve el editor entero —lee y escribe, firmado—, así que lo que queda es lo que convierte eso en «Spark es el motor de la plataforma y las tablas se mantienen solas»:

  • W3 — cerrar el ledger: 10 transacciones colgadas que ratify no puede ratificar. Es el siguiente paso, y arranca midiendo la ventana entre el commit y la lectura del snapshot;
  • W4 — el CREATE TABLE desde el editor;
  • B3 (resultados grandes), B7 (mantenimiento) y las 25 sondas contra ANSI — ⚠️ y el corpus de escritura, que nunca se midió.

Horizonte 2 · la gobernanza efectiva

Encender OpenFGA (hoy allowall) y decidir fila/columna. Hoy la política llega hasta tabla; el producto que vendemos habla de contexto gobernado, y eso acaba necesitando granularidad más fina.

Horizonte 3 · ⭐⭐ la proyección ontológica — el producto

CQRS a nivel de datos: el catálogo es el SoT de escritura; el grafo es un
READ-MODEL proyectado.

El catálogo promociona a contexto por proyección — no hay objeto intermedio. Y la cuña frente a Stardog es que nuestro catálogo es NATIVO: ellos importan metadata y aproximan permisos; nosotros heredamos. La pieza que lo hace posible es ReBAC (OpenFGA), porque la gobernanza del grafo tiene que emanar del catálogo por relación.

⚠️ Y lo que hay que resolver ahí: gobernar el TRAVERSING, no sólo el resolve. Un grafo permite llegar a un nodo por caminos que nadie autorizó explícitamente.

Horizonte 4 · los agentes

El agente NUNCA ve las tablas. Consume el grafo proyectado. Ése es el producto: Agentic Data Cloud, no un lakehouse con un chat encima.


12 · Las reglas que la práctica dejó

No son estilo: cada una costó una medición equivocada.

  1. ⭐⭐ Un veredicto no se calcula por AUSENCIA. «No apareció la palabra mala» no es un aprobado. Cada gate enumera sus salidas válidas y sale con código propio cuando falta alguna.
  2. ⭐⭐ La sonda tiene que medir el mismo objeto, la misma identidad, el mismo momento y el mismo vocabulario que el sistema real. Un verde sobre otra coordenada es peor que no medir.
  3. Un gate que sólo comprueba lo que NO se puede hacer aprueba un sistema que no puede hacer nada. Todo negativo necesita su positivo al lado.
  4. El formato de salida es parte del gate. Truncar un error por un extremo destruye la parte útil — y no siempre el mismo extremo.
  5. Un health-check dice que el proceso vive; sólo un e2e que ESCRIBA dice que el camino de escritura existe.
  6. Exponer un servicio cambia la clasificación de todo lo que ya devolvía. Lo que era diagnóstico interno pasa a ser público sin que nadie edite una línea.
  7. El gate de entrada de un motor se escribe ANTES de tocar el cluster. Trino llegó a F7 sin servir una sola query de usuario.
  8. Declarado ≠ efectivo. Un permiso «que está en la plantilla» y no existe es el peor síntoma posible. Corolario medido el 08-10: vercel env pull escribe KEY="" para las variables de tipo sensitive —sin avisar—, así que copiarlas de un entorno a otro crea variables vacías que vercel env ls lista como presentes. Presencia no es valor. La fuente de verdad de BRIDGE_JWT_SECRET es el secreto del cluster, que es contra quien valida el puente.
  9. ⭐⭐ Un fallback silencioso no causa el fallo: impide leerlo, y eso sale más caro. El editor sirvió DuckDB durante días sin un solo error, porque «el motor que quería no está» y «todo va bien» eran el mismo byte en la respuesta. ⇒ Donde haya degradación automática, que sea RUIDOSA o que no exista.
  10. Un endpoint de diagnóstico que no es público para Clerk no diagnostica nada. auth.protect() responde 404, no 401 — el mismo código con el que diría «no existo». Se leyó como «el deploy no llega» y costó tres deploys y un revert de vercel.json en el sitio equivocado. El repo YA lo avisaba en proxy.ts para /api/iceberg («costó una tarde encontrarlo») y aun así se repitió: un aviso en un comentario no es un gate.
  11. Antes de buscar la causa en el código, comprobar que el sistema está ENCENDIDO. El día que Spark «no servía», carbon-connect estaba en clusterOperation.stopped: true y spark-bridge en replicas: 0. Y el /healthz del puente devolvió 200 a las 12:23 y 503 a las 13:15: una sonda de salud es válida en su instante, no para el resto de la sesión.
  12. Los previews van detrás de Vercel SSO (all_except_custom_domains), así que un gate sin x-vercel-protection-bypass sólo puede medir producción — y medir sólo producción fue lo que dejó pasar días con Preview mal cableado.
  13. ⭐⭐ §12·2 no es un consejo: es el error que más veces se repitió el 08-10. Tres sondas seguidas midieron con el vocabulario equivocado y las tres produjeron un diagnóstico FALSO que apuntaba al sistema: · workspaceId: "probe-ws" → 401 «el puente rechaza» — era _es_uuid(); · main.default.dietsForbidden (unknown-table) «falta un grant» — el nombre lógico no existe en Iceberg: el físico es ds_<datasetId sin guiones>, y quien traduce es buildDuckReadPlan, no el motor; · sed sobre un .env con CRLF → \r en la cabecera ⇒ 401 por longitud. ⇒ Cuando una sonda dice que el sistema está roto, sospechar de la sonda primero. Cuesta 30 s descartarla y horas perseguir el fantasma.
  14. ⭐⭐⭐ UN COMANDO QUE NO FALLA NO HA HECHO NADA NECESARIAMENTE. Se repitió cuatro veces el 08-10, siempre con SUCCEEDED y efecto cero: ALTER TABLE … WRITE UNORDERED (el sort order ya estaba en 0), y las TRES vías de firmar un commit desde SQL (snapshot-property por catálogo, su variante .write., y spark.wap.id). ⇒ El veredicto se lee del ESTADO, no del código de salida.
  15. ⭐⭐ Un ROLLBACK que no se ha EJECUTADO no es un rollback: es una hipótesis. El de W1 estaba escrito y razonado —«tras el drop la location queda libre»— y era falso: liberaba la location, no el nombre (Lakekeeper hace soft-delete). Se supo al necesitarlo, con la tabla ya fuera del catálogo. Lo que la salvó (undrop) no estaba en el plan.
  16. ⭐⭐ EL MOMENTO de la sonda decide el veredicto — tres veces el mismo día: B8 era invisible porque todos los gates corrían en frío; el gate de B8 dio un «verde con reserva» falso por inferir del TIEMPO en vez de leer un contador; y el de W1 dio cuatro rojos falsos porque la sesión del puente cachea la metadata y medía la tabla vieja (⇒ REFRESH TABLE antes de medir).
  17. Lo que el motor no puede hacer, lo hace el CATÁLOGO. Resolver nombres lógicos y firmar el commit se intentaron primero en el motor y no eran posibles desde SQL. Hechas en la cara, valen para todos los motores a la vez. Es el mismo movimiento dos veces en un día: cuando algo no se puede hacer arriba ni abajo, suele tocar en la cintura.

⭐⭐⭐ 08-10 · LA CARA RESUELVE NOMBRES LÓGICOS — el patrón Unity Catalog

Hasta hoy la cara exponía ds_<uuid> como nombre de tabla, y el nombre lógico (diets) sólo existía en Index. Funcionaba porque duck-server lo resolvía con su propio AST (M2c) — y se rompió en cuanto entró un segundo motor: Spark recibía SELECT * FROM diets y contestaba TABLE_OR_VIEW_NOT_FOUND, porque en su catálogo la tabla se llama ds_6bfc4167….

Lo que dice el sector (medido, no supuesto):

«Clients reference the table by name, not by path, and use the catalog to
resolve the table's storage location.»
— Unity Catalog

Y al crear una managed table, UC crea un directorio con nombre generado al
azar
. Es exactamente nuestro ds_<uuid>sólo que ellos lo ponen en la
UBICACIÓN y nosotros lo pusimos en el NOMBRE.

En Iceberg el identificador (namespace.tabla) es independiente de la ubicación física: nada obligaba a meter el uuid en el nombre.

⇒ La resolución se ha movido al catálogo (lib/governance/catalog-names.ts), que es donde el estándar la pone. Con eso vale para Spark, DuckDB, Karma y PyIceberg sin tocar ninguno, y M2c deja de ser un requisito del motor.

⭐ Tres propiedades que se conservan a propósito:

  1. No autoriza. Traduce y ya; decide catalog-authz, después y sobre el nombre ya físico. Un permiso decidido en dos sitios acaba divergiendo.
  2. ds_<hex> sigue pasando intactowarehouse-writer no se toca.
  3. Un nombre que no existe se reenvía tal cual y muere con NoSuchTable. Un 403 diría «no puedes» cuando lo cierto es «no está».

⚠️ Límite dicho: sólo se traduce el segmento de TABLA. Las tablas de metadata llevan la tabla base dentro del namespace y ahí no se traduce (hoy las usa el mantenimiento, que ya va en físico).

⚠️ Y los nombres, medidos: de 89 datasets con tabla física, 54 son identificadores SQL válidos y 35 no (6 llevan punto). Se expone el nombre tal cual —lo que hace Databricks— así que esos 35 hay que citarlos con backticks. Se descartó slugificar: introduciría una SEGUNDA traducción, que es el problema que esto viene a quitar.

🏁 08-10 · el puente se autenticaba con el principal SIN lectura

CATALOG_CREDENTIAL del deployment apuntaba a secret/carbon-catalog-oauth-etl (lakekeeper-spark-etl), cuyo ÚNICO privilegio es CATALOG_MANAGE_CONTENT. Por eso SHOW NAMESPACES pasaba y loadTable daba Forbidden: el principal podía gestionar contenido y no leer datos.

Se arregló apuntando a lakekeeper-spark, con los mismos 7 privilegios que lakekeeper-duck-server — el motor al que sustituye. ⭐ Ése fue el criterio, y no «los que hagan falta»: paridad exacta con el motor reemplazado.

⚠️ Y ese criterio dejó de valer el mismo día, al llegar la escritura (W0). Allí no había paridad que copiar: DuckDB escribe sin pasar por la cara. El criterio pasó a ser el del catálogo —privilegio al conducto, política al objeto— y el puente acabó en lakekeeper-spark-editor, con identidad propia y sin TABLE_DROP. ⇒ Un criterio correcto tiene un dominio; copiarlo fuera de él es cómo se toman decisiones malas con buenos argumentos.

⚠️ Y la trampa que esto deja: SHOW NAMESPACES verde NO prueba que se puedan leer datos. Son privilegios distintos. Un gate de motor tiene que llegar al SELECT *.


13 · Los documentos que importan

DocPara qué
estela foto completa. Manda sobre todo lo demás
w-write-path-approach.mdel camino de ESCRITURA — fases W0-W5, las 7 decisiones y lo que queda. Sustituye a b-spark-bridge §B4
w3-ledger-idempotencia.mdla mini-spec de W3 — el censo del ledger, el e2e que faltaba y los dos bloqueantes de escritura que destapó. Desarrolla y corrige §D7 del approach
w1-sort-order-repair.mdel terreno de W1: la cirugía de metadata y las 3 hipótesis falsadas
b-spark-bridge-approach.mdel puente: fases B0-B7 · ⚠️ §B4 queda sustituido por w-write-path-approach.md
sustrato-02.mdel diario del 08-09: demolición de Trino, absorción de Spark, can_commit
sustrato-01.mdel del 08-08 · §7 = claims retiradas
platform-thesis.mdel norte: el cliente compra contexto
ontology-projection.mdel horizonte 3
⚠️ federated-catalog-wedge.mdse queda sin caso: sostiene que la cuña es la federación, y la federación salió con Trino

Los gates que se corren, y contra qué

npx tsx scripts/bridge/b6-gate-motor.ts <origen>              # ¿qué motor sirve?
npx tsx scripts/bridge/b6-gate-nombres.ts <ws> <ns> <tabla>   # nombres lógicos, ida y vuelta
npx tsx scripts/bridge/b8-gate-sesion-larga.ts <ws> <ns> <t>  # ⏱ tarda 6 min A PROPÓSITO
npx tsx scripts/governance/w2-gate-escritura-firmada.ts       # ¿el DML deja firma?
npx tsx scripts/governance/s5-0-principals-gate.ts            # la forma exacta de cada principal
npx tsx scripts/governance/w1-repair-cycle.ts <ns> <tabla>    # reparar un sort order huérfano