Published

El parser, al motor — spec

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

El parser, al motor — spec

Fecha: 2026-08-12. Sustituye al parser de regex de sql-parse.ts por el plan
analizado del motor, y con él cambia qué se autoriza: de sentencias a objetos.

Regla de esta casa: cada hecho lleva el comando que lo demuestra. Lo que no lo
lleva va marcado como pendiente o como opinión.

Manda sustrato-06.md donde discrepen.


1 · La pregunta que origina el documento

«¿Es normal que cada verbo nuevo cueste trabajo dedicado, o nos obliga la
naturaleza de nuestro sustrato?»

Las dos cosas, y la distinción es todo el documento. Enumerar operaciones es normal e inevitable: nadie se libra. Lo que no es normal es lo que enumeramos nosotros.

Qué enumeranCómo crece
Los líderesoperación → privilegio → objeto YA RESUELTOuna fila en una tabla declarativa
Carbon, hoyoperación → una regex que busca el destino en el TEXTOun problema de parsing por verbo

Lo primero es un modelo de permisos. Lo segundo es arqueología de cadenas — y falla de formas que no se anuncian. La noche del 08-11 lo demostró: cuatro defectos encadenados en el camino de la coordenada, cada uno tapando al siguiente, y ninguno detectable sin escribir SQL de verdad (n4-la-coordenada-desambigua.ts).


2 · El estado medido, hoy

Cinco verbos. sql-parse.ts:383:

INSERT INTO · UPDATE · DELETE FROM · MERGE INTO · CREATE [OR REPLACE] TABLE

Y dos listas que no se hablan. Es de donde sale el mensaje que confundió al owner al probar ALTER TABLE … ADD COLUMN:

classifyForRouting  → «esto escribe»          ALTER  ✅ está
locateDmlCommand    → «sé leer su destino»    ALTER  ❌ no está
        ⇒ 403 «no declara un destino reconocible»   (route.ts:479)

El error culpa a la sentencia del usuario de un desacuerdo entre dos piezas nuestras. Su SQL declaraba su destino perfectamente.

Por qué la lista es corta, y esto es lo importante: no es una gramática. Es la lista de sentencias de las que sabemos extraer un destino gobernable ANTES de ejecutar. Ese «antes» es el requisito duro — la puerta autoriza el write_target, firma el commit, lo anota en el ledger y hace idempotente el reintento, y todo eso necesita saber la tabla antes del round-trip.

Los cinco verbos no limitan el lenguaje. Limitan lo que sabemos gobernar. El lenguaje lo limita Spark más el catálogo de divergencias de Carbon SQL (spark.ts:229), y ahí sí hay riqueza.


3 · Qué hacen los líderes — los tres, el mismo patrón

3·1 · Apache Ranger sobre Spark

Un SparkSessionExtension engancha el optimizador de Catalyst y recorre el plan lógico como árbol de nodos (CreateTableCommand, DropTableCommand, InsertIntoHiveTable…). De cada nodo extrae base de datos, tabla y columnas ya resueltas y pregunta a Ranger.

Nadie parsea SQL. La autorización ocurre dentro del motor, después del análisis, sobre objetos que ya tienen identidad.

3·2 · Trino — el SPI de control de acceso

SystemAccessControl / ConnectorAccessControl: el motor analiza y llama de vuelta al plugin con los objetos resueltos. checkCanSelectFromColumns(identity, tabla, columnas). Y una pista de diseño que vale oro: las variantes checkCanSelectFromTable / …FromView fueron deprecadas en favor de la de columnas — porque el eje correcto no es «qué clase de sentencia es» sino «qué objeto toca, y con qué grano».

Sí: el SPI tiene decenas de métodos checkCan*. La enumeración existe también en ellos. Pero cada entrada es una pregunta sobre un objeto, no una regex.

3·3 · Unity Catalog — el vocabulario pequeño

Privilegios SELECT, MODIFY, USE SCHEMA, CREATE… sobre securables en el espacio de tres niveles catalog.schema.table, con herencia por los objetos contenedores.

Y aquí está la clave económica del asunto: el vocabulario de privilegios es diminuto mientras la superficie de verbos SQL es enorme. Cien verbos no son cien trabajos de gobernanza: son cien filas que caen sobre el mismo puñado de privilegios y una jerarquía que ya hereda.

La riqueza de verbos no multiplica la gobernanza — se proyecta sobre una retícula
fija y pequeña.


4 · La tesis

Dejar de autorizar SENTENCIAS. Autorizar OBJETOS.

Hoy Node lee el texto, adivina el verbo, adivina el destino y decide. Es una arquitectura de interceptación, y la interceptación paga por verbo, en parsing.

El destino: el motor resuelve (nos dice qué objetos toca y con qué grano), un mapa declarativo dice qué privilegio exige cada operación, y la cara aplica — que es exactamente lo que ya hace.

Y la mitad buena ya está construida. La cara (/api/iceberg/v1) autoriza por inquilino y por tabla, en cada petición, en el punto de acceso. Eso es el patrón de Trino y de UC. Lo que sobra es la capa de Node que lo duplica peor y antes.


5 · El mecanismo — 🏁 P·0 CORRIDO (2026-08-12)

Este apartado decía que la RPC AnalyzePlan de Spark Connect era «el camino bueno» porque devolvería los objetos con identidad estructurada, y marcaba esa hipótesis como a verificar. Se verificó. Es FALSA.

⛔ Lo que NO existe

Introspeccionados los métodos de AnalyzePlanRequest en el motor real (spark 4.1.2-stackable26.7.0, dentro del pod del puente):

schema · explain · tree_string · is_local · is_streaming · input_files
spark_version · ddl_parse · same_semantics · semantic_hash · persist
unpersist · get_storage_level · json_to_ddl

Ninguno responde «qué objetos toca esta sentencia». explain y tree_string son texto; input_files da rutas físicas; ddl_parse parsea un TIPO, no una sentencia. ⇒ No hay atajo estructurado, y buscarlo era perseguir una API que no existe.

✅ Lo que sí existe, y es mejor de lo que la spec suponía

1 · EXPLAIN EXTENDED planifica LAS CINCO formas sin ejecutar ninguna — incluidas las que hoy el editor rechaza:

select ✅ · update ✅ · merge ✅ · alter ✅ · ctas ✅

La lista de cinco verbos es una limitación NUESTRA, no del motor. Spark ya sabe planificar ALTER TABLE … ADD COLUMN; lo único que falta es que le preguntemos.

2 · El plan lleva la coordenada SEGMENTADA, no una cadena que haya que partir:

'UpdateTable [assignment('kcal, 1)], ('id = 2)
+- 'UnresolvedRelation [main, default, diets], [__required_write_privileges__=UPDATE], false

3 · ⭐⭐⭐ Y EL MOTOR EMITE EL PRIVILEGIO QUE EXIGE. __required_write_privileges__=UPDATE, por relación. Eso es media tabla de P·1 escrita por Spark.

4 · Y distingue el DESTINO de la FUENTE, que es justo lo que locateDmlCommand intenta adivinar con una regex:

'MergeIntoTable …
:- 'UnresolvedRelation [a, b, d], [__required_write_privileges__=UPDATE], false   ← destino
+- 'UnresolvedRelation [a, b, o], [], false                                        ← fuente

5 · El ALTER nombra objeto y operación: 'UnresolvedTable [karma, test, observatorios], ALTER TABLE ... ADD COLUMN.

6 · schema sí da estructura para lecturas (columnas y tipos en JSON) — el grano que pide checkCanSelectFromColumns de Trino.

La consecuencia para el diseño

El texto del plan ES el contrato. No es un apaño temporal a la espera de una API
mejor: la API mejor no existe, y el texto lleva más de lo que necesitábamos.

plan-reader.ts deja de ser un raspador provisional y pasa a ser la pieza arquitectónica central, con lo que eso obliga: su gramática es un contrato con el motor, y cambia cuando cambie la versión de Spark. Se pinnea y se vigila igual que los 7 interruptores de Carbon SQL.

⚠️ Y una nota de sesión medida en el mismo P·0: una sesión cruda sólo ve spark_catalog. Los catálogos del inquilino los registra el puente por sesión, así que cualquier sonda que planifique tiene que hacerlo en la sesión del inquilino o medirá un motor que no es el que sirve.


6 · Lo que NO se hereda — la parte honesta

Mover el parser regala la sintaxis, no la gobernanza. Por cada operación sigue habiendo que decidir:

  1. Qué privilegio exige sobre qué objeto y con qué grano.
  2. Qué se firma y qué se anota en el ledger.
  3. Cómo se hace idempotente un reintento.
  4. Qué tiene que reconciliar Index después.

El punto 4 es el que muerde, y ya está medido con el caso del owner. ALTER TABLE … ADD COLUMN cambia el esquema, e Index guarda su propia copia en datasets.schema

  • dataset_schema_versions. Esa copia sólo se escribe cuando la tabla nace (table-materialise.ts:211) o desde los caminos de ingesta (dataset-writer, /api/datasets/ingest, fileBackedDatasetUpload). Ninguno es el editor.

⇒ Un ADD COLUMN desde el editor dejaría Iceberg con 8 columnas e Index diciendo 7, y todo lo que lee de Index —pickers, linaje, la UI del Warehouse, la ontología— enseñaría un esquema rancio sin que nada falle. Es la misma familia de divergencia que los dos vocabularios de tipos de observatorios (§ del sustrato-06).

Un verbo sin su reconciliación no es un verbo nuevo: es una mentira nueva.


7 · Fases

Cada una con su gate. Ninguna se declara hecha sin él.

QuéGate
🏁 P·0¿Qué devuelve AnalyzePlan de verdad?HECHO 2026-08-12. Hipótesis refutada: no hay API estructurada. Pero el plan de texto lleva coordenada segmentada, privilegio requerido y rol destino/fuente (§5)corrido en el pod del puente contra spark 4.1.2-stackable26.7.0, las 5 formas
P·1El inventario de operaciones: qué verbos queremos, y para cada uno privilegio · objeto · grano · reconciliación en Index. ⭐ Más barato de lo previsto: el motor ya emite el privilegio de escritura por relación, así que P·1 es sobre todo traducir su vocabulario al nuestro (TABLE_WRITE_DATA…), no inventarloel documento, revisado · y una sonda que compare lo que el plan dice exigir contra lo que CATALOG_OPS exige hoy
P·2Los GRANTS DE USUARIO — no un autorizador nuevo, y tampoco OpenFGA todavía. El mapa (CATALOG_OPS) y el decisor (decidePrivilege) ya existen; lo que no existe es un solo grant de usuario, así que la retícula no se puede encenderprincipal_grants con filas principal_kind='user' · y el control negativo: un usuario SIN grant recibe una denegación de verdad, no un allow por omisión
P·3El plan del motor pasa a MANDAR en el descubrimiento de objetos (hoy sólo audita, incluso en enforce)⚠️ el criterio se corrigió — ver §7·bis. No es «0 discrepancias»: es 0 sin veredicto, más el privilegio completo y el coste medido
🏁 P·4El plan deja de ser SUPLENTE — 2026-08-15. Ver §7·ter: el enunciado original («retirar locateDmlCommand») resultó estar mal planteadodmlTarget sólo se calcula con PLAN_MANDA apagado
P·5Los verbos nuevos, uno a uno, cada uno con su reconciliación de Index. ALTER TABLE … ADD COLUMN es el primeropor verbo: e2e que escribe Y comprueba que Index no quedó rancio

⚠️ P·3 antes que P·4, y no al revés. Retirar el escáner viejo antes de fiarse del lector del plan es cambiar un mecanismo medido por uno sin medir. Hoy el diferencial dice 7 discrepancias: cada una es una decisión pendiente sobre cuál de los dos miente, y hasta que sean cero no se puede retirar al perdedor.


7·ter · ⭐⭐ P·4 — el enunciado estaba mal, y el estado del documento también (2026-08-15)

Este apartado se escribe porque el documento llevaba tres días diciendo algo que ya no
era verdad
, y eso mandó a mirar al sitio equivocado. La disciplina de la casa aplicada
a sus propios papeles: lo primero es medir el estado, no leerlo.

Lo que el documento decía pendiente y estaba HECHO

fasedecíamedido el 08-15
P·2 grants de usuariopendiente🏁 ws-admin con 4 personas, USER_AUTHZ=enforce, y el control negativo medido en f2-gate-federacion.ts
P·3 el plan mandapendiente🏁 planManda: true en producción (/api/diag/motor)
P·5 verbos nuevospendiente🏁 el ALTER … ADD COLUMN ya se decide por la familia del PLAN, no por la palabra

⭐⭐⭐ Y por qué el enunciado de P·4 estaba mal

Decía «retirar locateDmlCommand y la lista de 5 verbos». No se puede, y no se debe — porque esa pieza tiene dos consumidores con necesidades distintas, y sólo uno era legacy:

consumidorqué hace¿legacy?
extractDmlTargetDESCUBRE el destino en el texto — desde P·3 lo da el plan
qualifyWriteTargetREESCRIBE el nombre por su FQN físicano — hay que saber DÓNDE está el nombre en la cadena, y ningún plan te lo dice

⇒ P·4 no es borrar una función: es quitarle la decisión al texto, dejándole la manipulación de texto, que es irreducible.

Lo que sí estaba sin hacer, y es lo que se hizo

El destino se sacaba siempre con la regex, y el plan sólo entraba si la regex fallaba (writeTarget: dmlTarget ?? undefined). Es decir: el mecanismo nuevo estaba construido, medido y encendido en producción… y cedía el paso al viejo cada vez que el viejo acertaba.

Un paradigma que sólo actúa cuando el otro falla no está asentado: está de guardia.

⇒ Con PLAN_MANDA encendido, dmlTarget ya no se calcula: el destino lo da el plan y sólo el plan. La regex queda como camino de los despliegues con la bandera apagada.

⚠️ Lo que esto NO hace: retirar la regex del repo. Eso exige que la bandera no se pueda apagar, y es otra decisión. ⚠️ Y falta su medición en vivo: una escritura por el SQL Editor contra producción, con el destino saliendo del plan. Los 263 tests de lib/compute pasan, pero eso es en frío.


7·bis · P·3 — el criterio que escribí estaba mal, y lo hecho el 2026-08-12

⛔ «check:plan-diferencial a 0 discrepancias» era insatisfacible

Las 10 discrepancias no son defectos que arreglar: en las 10 gana el lector nuevo, y el viejo no puede alcanzarle por construcciónscanTableRefs sólo mira tras FROM/JOIN, así que jamás verá el destino de un UPDATE. Llegar a 0 exigiría mejorar el escáner que P·4 va a borrar.

El criterio correcto es «0 discrepancias SIN VEREDICTO», que es lo que el trinquete ya mide (nuevas 0). Cada diferencia investigada y con su ganador nombrado. Un número redondo como meta era, otra vez, un umbral sin derivar (§12·38).

🏁 Lo hecho, con su medición

QuéEvidencia
🏁⭐⭐ El privilegio compuesto ya no se cortala clase [A-Z_]+ no incluía la coma ⇒ INSERT OVERWRITE se leía INSERT (el borrado desaparecía) y MERGE se leía UPDATE (la inserción, invisible). Ahora privilegiosRequeridos: string[]
🏁Dos casos nuevos en el corpus, capturados del motor realinsert-overwrite['INSERT','DELETE'] · merge-con-insert['UPDATE','INSERT'] · 33 fixtures
🏁Otro verbo perdido por la regexextractDmlTarget devuelve null para INSERT OVERWRITE: locateDmlCommand busca INSERT INTO y un overwrite no lleva INTO. Mismo accidente que bloquea ALTER TABLE … ADD COLUMN
🏁El coste de esperar el plan, medidover abajo

⭐ El coste de que el plan MANDE — medido, y es una sorpresa

Contra el motor real, sesión caliente, dentro del pod del puente:

SELECT 1 (ejecutar)               p50  54.2 ms
EXPLAIN EXTENDED de un SELECT     p50  34.0 ms
EXPLAIN EXTENDED de un UPDATE     p50  35.5 ms
EXPLAIN EXTENDED de un MERGE      p50  31.3 ms

Esperar el plan cuesta ~35 ms y es MÁS BARATO que ejecutar SELECT 1, porque no lanza job. Y es plano: no crece con la complejidad de la sentencia, porque el plan parsed es anterior a la resolución.

⇒ El motivo escrito para que shadow no espere —«medir no puede costarle latencia al usuario»se sostiene mucho menos de lo que parecía. Y el trabajo del motor ya se está pagando hoy: en shadow el EXPLAIN se lanza igual, sólo que sin esperarlo. Lo que P·3 añade no es carga: es 35 ms de latencia.

⚠️ Medido dentro del pod: excluye el salto HTTP de Vercel al puente. El número de punta a punta hay que medirlo aparte antes de flipar nada.

Lo que queda de P·3

El cableado: que route.ts y junction.ts tomen los objetos del plan en vez de scanTableRefs/extractDmlTarget. No está hecho, y arrastra dos decisiones de comportamiento que hay que tomar a propósito, no de rebote:

  1. Fail-closed de verdad: si el motor no contesta, no se ejecuta. Hoy la query sale igual porque el auditor no puede ser un modo de fallo.
  2. Los 35 ms pasan a estar en el camino de toda query.

8 · Los riesgos, sin maquillar

  1. El plan del motor exige un round-trip ANTES de ejecutar. Hoy en shadow no se espera (void auditarPlan(...)) precisamente para no cobrarle latencia al usuario. En enforce sí se espera. Con la sesión caliente son ~125 ms; en frío, 5.832 ms. El coste hay que medirlo antes de prometer la arquitectura, no después.
  2. Fallo cerrado en el peor momento. Si el motor no contesta, hoy la query se ejecuta igual (el auditor no puede ser un modo de fallo). Cuando el plan MANDE, no contestar significa no ejecutar. Es la decisión correcta y hay que tomarla a propósito.
  3. Las 7 discrepancias del diferencial no son ruido: son sitios donde el lector y el escáner dicen cosas distintas. Cada una hay que resolverla a mano.
  4. Index se vuelve el cuello de botella de la verdad. Cuantos más verbos cambien esquema o metadata, más superficie de reconciliación. Sin ella, la riqueza de verbos compra divergencia.

8·bis · ⭐⭐ ¿No es esto construir por el camino malo? — y qué NO adoptamos

La objeción es correcta y hay que contestarla fase por fase, porque cuatro de las seis son medir, escribir y borrar, no construir: P·0 es una sonda · P·1 es una tabla · P·3 usa el analizador de Spark · P·4 BORRA nuestro parser. La única pieza que sólo podemos hacer nosotros es P·5, porque nadie de fuera conoce Index.

Pero P·2 SÍ era el camino malo, y se corrige aquí. Decía «una función que decide», o sea un motor de políticas propio. Y OpenFGA ya está desplegado —hoy autoriza a Lakekeeper, es decir al catálogo, que es exactamente el punto de acceso donde aplican Trino y UC—. En el repo no hay ni una línea suya: sólo un comentario en junction.ts:1473 que lo aparca como Horizonte 2. ⇒ P·2 es enchufar lo que ya está de pie, no escribir un decisor al lado.

Ranger: el patrón sí, el producto no

Es una extensión JVM de CatalystNuestro Spark es una imagen de Stackable a la que hablamos por Spark Connect + puente Python. No controlamos ese JVM
Trae su propio almacén de políticas (Admin + BD + Solr)Sería una segunda fuente de verdad. La tesis de la casa es que la política emana del catálogo ⇒ cambiaríamos gobernanza por sincronización
Su modelo es de forma HiveNuestra identidad es Clerk → workspace
No cabeCPUS_ALL_REGIONS = 12, cuatro pods en un nodo

Y el matiz que decide: Ranger autoriza dentro del motor, lo cual sólo sirve si controlas su JVM. El modelo de Trino —el motor analiza y llama fuera— es el que sí podemos hacer, y la cara ya es ese punto de llamada.

⭐⭐⭐ Y una corrección de bulto: LA RETÍCULA YA EXISTE

Este documento dijo que Carbon tiene «pocos verbos y sin retícula». La segunda mitad es falsa, y comprobarla cambia el orden de las fases.

catalog-authz.ts:58 es literalmente el patrón de Trino y UC — un mapa declarativo operación → privilegio → alcance:

listNamespaces → SCHEMA_LIST            scope: namespace  resolve
createTable    → TABLE_CREATE           scope: namespace  ddl
updateTable    → TABLE_WRITE_DATA       scope: table      write
dropTable      → TABLE_DROP             scope: table      ddl
loadTable      → TABLE_READ_PROPERTIES  (+ TABLE_READ_DATA si delega credencial)

Y el decisor también existe: decidePrivilege(chain, grants, p) resuelve por cadena, o sea con herencia por contenedor. Medido el 2026-08-12:

principal_grants ......... 368 filas
por tipo de principal .... service 368   ⛔ CERO de usuario
privilegios concedidos ... 17 distintos
columnas ................. workspace_id · principal_kind · principal_id
                           principal_role · catalog_role · privilege
                           securable_kind · securable_id

securable_kind + securable_id es el modelo de securables de Unity Catalog.

La cadena de la industria es: objetos resueltos → mapa → decisor. Nosotros ya tenemos el segundo y el tercero. Lo que falta es el primero (que es todo el problema del parser y de N·5) y los grants de usuario.

⇒ Y de ahí la conclusión que reordena el trabajo: los verbos YA emanan de la retícula — para todo lo que pasa por la cara. Lo que no emana es la pre-autorización de Node en el editor, que es justo la capa de interceptación que esta spec retira. Hacer más retícula no desbloquea verbos. Quitar la interceptación, sí.

⚠️ Entonces, ¿OpenFGA primero?

Es tentador —«dejo la retícula lista y de ahí emana todo»— pero el orden falla por dos sitios medidos:

  1. No es el eslabón que falta. OpenFGA mejora el decisor (ReBAC a escala, y hoy autoriza a Lakekeeper). El decisor no es el cuello de botella: de los cuatro defectos de la noche del 08-11, los cuatro fueron de RESOLUCIÓN y ninguno de decisión.
  2. OpenFGA exige un modelo de autorización (tipos y relaciones en su DSL) que no se puede escribir bien sin saber qué objetos y operaciones hay que cubrir — y ese conocimiento es justamente CATALOG_OPS + securable_kind. Escribir el modelo antes es adivinarlo, y un modelo de OpenFGA mal puesto se re-migra caro.

Lo que SÍ hay que hacer ya, y es la parte buena de la intuición: los grants de usuario. Con 368/368 de servicios, decidePrivilege(usuario, …) denegaría a todo el mundo, así que la retícula no se puede encender aunque esté perfecta. Eso es independiente de OpenFGA y bloquea cualquier operación gobernada de usuario.

Y una aclaración que costó una confusión

Unity Catalog tiene pocos PRIVILEGIOS, no pocos VERBOS. ALTER TABLE … ADD COLUMN funciona en Databricks sin ceremonia; lo que UC decide es que exige MODIFY sobre la tabla (o propiedad, según el cambio) más USE SCHEMA/USE CATALOG sobre sus contenedores. El vocabulario pequeño es la CAUSA de que puedan permitirse muchos verbos, no un límite. Carbon hoy tiene lo contrario: pocos verbos y sin retícula.


9 · Lo que se decide aquí

  • a la riqueza de verbos estilo Databricks, y el camino es el de los líderes: autorizar objetos resueltos, no sentencias.
  • No a añadir verbos a la regex mientras tanto. Cada uno que se añada así es deuda con intereses: hay que quitarlo después, y mientras tanto miente.
  • La excepción justificada: ALTER TABLE … ADD COLUMN puede entrar antes que toda la arquitectura si entra con su reconciliación de Index, porque es lo que el owner necesita para probar N·5 sin tocar dato. Sin la reconciliación, no.

10 · Fuentes