El parser, al motor — spec
Fecha: 2026-08-12. Sustituye al parser de regex de
sql-parse.tspor 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.mddonde 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é enumeran | Cómo crece | |
|---|---|---|
| Los líderes | operación → privilegio → objeto YA RESUELTO | una fila en una tabla declarativa |
| Carbon, hoy | operación → una regex que busca el destino en el TEXTO | un 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:
- Qué privilegio exige sobre qué objeto y con qué grano.
- Qué se firma y qué se anota en el ledger.
- Cómo se hace idempotente un reintento.
- ⭐ 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·1 | El 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 inventarlo | el documento, revisado · y una sonda que compare lo que el plan dice exigir contra lo que CATALOG_OPS exige hoy |
| P·2 | ⭐ Los 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 encender | principal_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·3 | El 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·4 | El plan deja de ser SUPLENTE — 2026-08-15. Ver §7·ter: el enunciado original («retirar locateDmlCommand») resultó estar mal planteado | dmlTarget sólo se calcula con PLAN_MANDA apagado |
| P·5 | Los verbos nuevos, uno a uno, cada uno con su reconciliación de Index. ALTER TABLE … ADD COLUMN es el primero | por 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
| fase | decía | medido el 08-15 |
|---|---|---|
| P·2 grants de usuario | pendiente | 🏁 ws-admin con 4 personas, USER_AUTHZ=enforce, y el control negativo medido en f2-gate-federacion.ts |
| P·3 el plan manda | pendiente | 🏁 planManda: true en producción (/api/diag/motor) |
| P·5 verbos nuevos | pendiente | 🏁 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:
| consumidor | qué hace | ¿legacy? |
|---|---|---|
extractDmlTarget | DESCUBRE el destino en el texto | sí — desde P·3 lo da el plan |
qualifyWriteTarget | REESCRIBE el nombre por su FQN física | no — 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ón — scanTableRefs 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 corta | la 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 real | insert-overwrite → ['INSERT','DELETE'] · merge-con-insert → ['UPDATE','INSERT'] · 33 fixtures |
| 🏁 | ⭐ Otro verbo perdido por la regex | extractDmlTarget 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, medido | ver 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:
- 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.
- Los 35 ms pasan a estar en el camino de toda query.
8 · Los riesgos, sin maquillar
- El plan del motor exige un round-trip ANTES de ejecutar. Hoy en
shadowno se espera (void auditarPlan(...)) precisamente para no cobrarle latencia al usuario. Enenforcesí 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. - 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.
- 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.
- 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 Catalyst | Nuestro 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 Hive | Nuestra identidad es Clerk → workspace |
| No cabe | CPUS_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:
- 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.
- 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í
- Sí 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 COLUMNpuede 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
- Ranger sobre Spark —
SparkSessionExtensiony el recorrido del plan lógico: spark-authorizer · SPARK-45004 - Trino — SPI de control de acceso y
checkCanSelectFromColumns: SystemAccessControl.java · SPI overview - Unity Catalog — privilegios y securables con herencia: permissions model · privileges reference
- Spark Connect —
AnalyzePlany el cliente Python: Spark Connect overview · core.py