El parser · que parsee el motor — approach de construcción
Fecha: 2026-08-11. Approach de P1·1 de
mapa-computo.md§7:
«consolidar el parseo del camino de datos — terminar lo que M3 empezó».Decisión del owner: opción C pura — el plano de control no parsea SQL; le pide
el plan al motor. Tomada sobre la investigación de §1 y con el gate previo P·0 en
verde (§2).Manda
sustrato-04.mdsobre los hechos. Presupone
carbon-sql-contract-approach.md, que cerró el
dialecto (C0-C5) y dejó el corpus que aquí se reutiliza como suite de aceptación.Regla de siempre: lo medido lleva su comando; lo demás va marcado como propuesta.
⭐⭐ ESTADO — remedido el 2026-08-15. Leer esto antes que el resto del documento.
La tesis sigue vigente: el plano de control no parsea SQL, le pide el plan al motor.
Lo que había caducado eran los NÚMEROS y el ESTADO — y con ellos, la idea de cuánto
faltaba. El censo del cuerpo (08-11) decía «los 9 exports siguen en uso» con 40 · 24 ·
21 · 16. Medido hoy, sin tests y sin el propiosql-parse.ts:
export decía (08-11) hoy scanTableRefs40 5 extractDmlTarget24 3 rewriteDottedFromRefs21 8 splitCtas16 1 locateDmlCommand3 1 ⇒ El parser de texto ya está desplazado en el camino de datos, no pendiente de
desplazar.PLAN_MANDAestá encendido en producción y desde el 08-15 el destino de
escritura lo da el plan y sólo el plan (P·4 · ver
parser-al-motor-spec.md§7·ter).⭐⭐ Y P·7 («retirar lo que sobre de
sql-parse.ts») está mal enunciado, por la misma
razón que lo estaba P·4:locateDmlCommandtiene dos consumidores y sólo uno era
legacy —extractDmlTargetdescubría (eso lo hace ya el plan), pero
qualifyWriteTargetreescribe el nombre por su FQN física, y saber DÓNDE está el
nombre en la cadena es manipulación de texto que ningún plan te da.⇒ Lo que queda de
sql-parse.tsno es deuda: es la parte de texto que sigue siendo
texto. Retirarla del todo exigiría quePLAN_MANDAno se pueda apagar, y eso es otra
decisión.⚠️ Y la cabecera dice «manda
sustrato-04.md»: el vigente essustrato-07.md.
0 · El problema, en una frase
Tratamos el SQL como texto. Seis fallos de dialecto en una semana, una sola causa
(mapa-computo.md§2·bis) — y desde entonces, dos más: el citado
con comillas dobles (D1) y la mitigación delDELETE, que exige reescribir un
predicado y que con una regex sería la séptima repetición del mismo error. Esta vez
el precio no es unParseException: son filas del cliente.
1 · Por qué C, y no un parser propio — lo que dice la industria
1·1 · «Tokenizador» no era el concepto
El pipeline canónico tiene cinco etapas, y cada una contesta otra pregunta:
lexer → parser → AST → ⭐ BINDER/ANALYZER → plan lógico → optimizador
(resuelve los nombres contra el CATÁLOGO)
Un tokenizador es la etapa 0. Lo que la puerta necesita es el AST (verbo, destino, referencias) y, para gobernar, el binder — porque «qué tablas toca esta query» sólo tiene respuesta exacta después de resolver nombres.
1·2 · Nadie escribe el parser a mano
| Sistema | Cómo |
|---|---|
| Spark · Trino · Flink | gramática ANTLR4 (.g4) |
| Calcite (el de Furnace) | JavaCC |
| PostgreSQL · MySQL | bison/yacc |
| SQLite | Lemon |
| DuckDB | libpg_query — el parser de Postgres, extraído |
| ⭐ Vitess — un proxy, como nosotros | goyacc, y lo dice: «escribirlo a mano no habría sido una opción» |
⇒ La propuesta inicial de este autor —«tokenizador + reconocedor de estructura a mano»— era correcta en la dirección y equivocada en el mecanismo. Es exactamente la crítica que el repo le hizo a Junction frente a Furnace, aplicada a sí mismo.
1·3 · Y el jugador top que hace NUESTRO trabajo no parsea
Unity Catalog obtiene el linaje de los planes de ejecución de Spark en runtime, no del texto de la query. El plano de control de Databricks no tiene un parser de SQL: instrumenta el motor y lee su plan. Ése es el concepto que aquí se adopta.
1·4 · Las tres opciones, y por qué C
| Qué | Veredicto | |
|---|---|---|
| A | parser propio a mano | ⛔ descartada: reinventar mal una rueda de 40 años |
| B | parser generado en Node (dt-sql-parser, ANTLR4, dialecto Spark) | viable, pero puede discrepar del motor: es otra gramática |
| ⭐ C | pedirle el plan al motor | elegida: no puede discrepar del motor, porque es el motor |
⭐ El argumento que decide: cualquier parser propio —a mano o generado— es una segunda opinión sobre qué significa una query, y dos opiniones divergen. El plan del motor no es una opinión sobre Spark: es Spark.
2 · 🏁 P·0 — el gate previo, corrido
scripts/bridge/p0-gate-plan-motor.ts, salida 0 (2026-08-11):
| Qué se probó | Resultado | |
|---|---|---|
| ① | EXPLAIN EXTENDED sobre los verbos | 6/6 — SELECT · UPDATE · DELETE · MERGE · CTAS · INSERT |
| ② | ⭐ efectos secundarios | CERO — filas y snapshot idénticos antes/después en los seis, y el CTAS no creó la tabla |
| ③ | el dato de gobernanza está en el plan | sí — verbo, destino y relaciones |
| ④ | sintaxis rota | falla (PARSE_SYNTAX_ERROR) |
| ⑤ | referencias que no existen | las nombra igual |
| ⑥ | coste | ~373 ms por query |
Y tres regalos que ningún parser propio habría dado:
'UpdateTable [assignment('i, 999)], ('id = 1)
+- 'UnresolvedRelation [main, default, c3_corpus_num], [__required_write_privileges__=UPDATE], false
'UnresolvedTableValuedFunction [read_parquet], [s3://x/y], false
- El destino ya viene partido en namespace — nada que trocear.
- ⭐
__required_write_privileges__: el propio Spark declara qué privilegio hace falta. Es el dato exacto que la puerta necesita para autorizar. - ⭐
UnresolvedTableValuedFunctiondistingue nativamente una función de tabla de una tabla — el vector H5 (read_parquetleyendo el object store) para el quescanTableRefstiene 95 líneas escritas a mano.
⚠️ Y el ④ empezó marcando rojo por una aserción equivocada: pedía que EXPLAIN
fallara con un nombre inexistente. Ésa no es la pregunta — quien decide qué existe y
de quién es no es Spark, es Index, y debe seguir siéndolo. Fue la 5ª vez en la
jornada que una aserción propia dio rojo sobre un sistema sano.
3 · ⭐⭐ La decisión de diseño que ordena todo: el plan PARSED, no el ANALYZED
Contra-intuitivo, y es el eje del approach.
| Parsed Logical Plan | Analyzed Logical Plan | |
|---|---|---|
| Resuelve nombres contra el catálogo | ⛔ no | ✅ sí |
| Existe aunque el nombre no exista | ✅ sí | ⛔ no |
Lleva 'UnresolvedRelation [partes] + privilegio | ✅ | — (ya resuelto) |
| Depende del catálogo / del inquilino | ⛔ no | ✅ sí |
Se usa el PARSED, y por tres razones que se refuerzan:
- ⭐ No invierte el orden de la autorización. Analizar obliga a Spark a resolver contra la cara, es decir, a autorizar durante el análisis. Con el parsed, Spark hace de parser puro y la decisión sigue siendo de la puerta — que es la arquitectura que este repo defiende desde §4·1 del sustrato.
- ⭐ Los hechos existen antes de saber si el nombre resuelve, que es justo lo que hace falta: primero se sabe QUÉ se referencia, luego Index dice SI se puede.
- ⭐⭐ No depende del inquilino ⇒ la caché es global. El parsed plan es función pura del texto: mismo SQL, mismo plan, para todos. Eso convierte los 373 ms en un coste de primera vez, no por query. Un plan cacheado no filtra nada entre inquilinos porque su única entrada es el SQL, que ya es del que pregunta.
⇒ La contrapartida, dicha: la resolución de nombres sigue siendo nuestra (Index), y eso está bien — es la pieza que nos hace un catálogo gobernado y no un proxy.
4 · Los dos trabajos que hoy se confunden: RECONOCER y REESCRIBIR
Es la distinción que el approach necesita para no prometer de más.
| Qué es | Hoy | Con C | |
|---|---|---|---|
| RECONOCER | qué verbo, qué destino, qué tablas, qué funciones | scanTableRefs · locateDmlCommand · extractDmlTarget · extractCteNames | ⭐ lo da el plan, al 100% |
| REESCRIBIR | cualificar el destino, anteponer CTEs de vista, expandir | qualifyWriteTarget · mergeViewCtes · rewriteDottedFromRefs · splitCtas | ⚠️ sigue siendo nuestro |
⛔ C no elimina la reescritura, y decirlo importa: quien resuelve un plan no reescribe un SQL. Lo que sí hace es hacerla casi innecesaria, y por dos vías ya medidas:
- 🏁 B9 (
scripts/bridge/b9-use-namespace.ts, salida 0): unUSE <ns>por sesión hace que el nombre pelado resuelva ⇒ desaparece el prólogo de CTEs para el caso común (90 de 144 tablas están enmain.default), y con él tres de los seis fallos de la semana. - 🏁 C1:
lib/warehouse/query/ident.tsya es el único citador. La reescritura que quede pasa por ahí, no por plantillas sueltas.
⇒ Queda como reescritura legítima: la expansión de vistas (que es semántica nuestra, no del motor) y las queries que cruzan namespaces.
5 · La forma de la pieza
5·1 · El contrato de salida
/** Lo que la puerta necesita saber de una sentencia, y NADA más. */
interface HechosDelPlan {
verbo: 'SELECT' | 'UPDATE' | 'DELETE' | 'MERGE' | 'INSERT'
| 'CREATE_TABLE' | 'CTAS' | 'OTRO';
/** El destino de escritura, ya partido: ['main','default','diets']. */
destino: string[] | null;
/** ⭐ Lo que Spark declara que hace falta: 'UPDATE' | 'DELETE' | … */
privilegioRequerido: string | null;
/** TODA referencia en posición de tabla, resuelva o no. */
referencias: string[][];
/** ⭐ Funciones de tabla — el vector H5, separado por el propio motor. */
funcionesDeTabla: string[];
}
⭐ El contrato NO expone el plan. Si HechosDelPlan filtrara el texto del plan, cada
consumidor acabaría leyéndolo a su manera y tendríamos diez lectores en vez de diez
regex. Se extrae una vez, se tipa, y el texto muere ahí.
5·2 · El lector, y su disciplina
lib/warehouse/query/plan-reader.ts — reconoce las formas de nodo del Parsed Plan.
⚠️ Sí, sigue siendo extracción de texto. No se disimula. Lo que cambia es de qué texto:
| El SQL del usuario | El plan de Spark | |
|---|---|---|
| Quién lo escribe | una persona, adversarialmente | una máquina |
| Regularidad | ninguna | alta y estable |
| ¿Puede discrepar del motor? | ✅ constantemente | ⛔ es el motor |
| ¿Versionado? | no | ✅ con el motor ⇒ lo guarda el bundle de C5 |
Tres reglas, no negociables:
- Fail-closed ante lo desconocido. Una forma de nodo que el lector no reconoce rechaza la query, no la deja pasar. §12·9: un fallback silencioso no causa el fallo, impide leerlo.
- ⛔ Sin
try { plan } catch { regex }. No hay degradación al camino viejo. - Un solo sitio. El lector es el único que ve texto de plan.
5·3 · La caché
Clave: sha256(sql). Global, no por inquilino — §3·3 explica por qué es seguro y por
qué es lo que convierte 373 ms en un coste de primera vez.
6 · Las fases, con su gate
| Qué | Gate | |
|---|---|---|
| P·0 | ¿es viable? | 🏁 p0-gate-plan-motor.ts salida 0 |
| P·1 | fixtures: capturar el plan de N sentencias y congelarlas | 🏁 p1-capturar-fixtures.ts · 29/31 planes + 2 negativas, estables con --trinquete — ver §6·bis |
| P·2 | el lector (plan-reader.ts) + sus tipos | 🏁 37/37 en frío · los 9 esperados provisional promovidos por medición — ver §6·ter |
| P·3 | ⭐ diferencial contra los escáneres viejos | 🏁 npm run check:plan-diferencial · 7 discrepancias, las 7 estudiadas — ver §6·quater |
| P·4·0 | ⭐ sobre qué SQL se pide el plan | 🏁 p4-0-gate-sobre-que-sql.ts salida 0 — sobre el SQL que se ejecuta. Ver §6·quinquies |
| P·4 | ⭐ el plan AUDITA a la puerta (reformulada por P·4·0) | 🏁 plan-auditor.ts · 12 tests · en vivo acuerdo en escritura y CTAS, con W3·b y W4 verdes — ver §6·sexies |
| P·4·bis·0 | ⭐⭐ hacer el criterio EVALUABLE | 🏁 el auditor persiste sus desacuerdos + p4bis-criterio-de-salida.ts — ver §6·duodecies. ⚠️ no bastaba: ver P·4·bis·1 |
| P·4·bis·1 | ⭐⭐⭐ el criterio era INSATISFACIBLE — el latido | 🏁 la ventana ya no exige un fallo para arrancar · ⓪ distingue off de shadow sano · ③ mide queries auditadas — ver §6·terdecies |
| P·4·bis | ⭐ retirar el segundo parser — el DESTINO, no una posibilidad | ⏳ abierto y por fin ALCANZABLE: falta encender la sombra en producción, luego la ventana |
| P·5·a | ⭐⭐ el destino cualificado, tras el colapso | 🏁 la tercera ruta del mismo fallo · desbloquea P·6 — ver §6·octies |
| P·6 | ⭐ el guardián del DELETE | 🏁 VERDE: bloquea · el rodeo funciona · y el control demuestra el daño |
| P·5·b·1 | ⭐ el puente fija el namespace de sesión | 🏁 desplegado y verificado en frío — ver §6·nonies |
| P·5·b·2·a | ⭐⭐ el default de lectura pasa a ser POR WORKSPACE | 🏁 p5b2-gate-namespace-por-workspace.ts salida 0 — ver §6·undecies |
| P·5·b·2·b | Node salta el prólogo cuando el namespace del dataset coincide con el de la sesión | desbloqueado por ·a: la condición es local, no depende de elegir un namespace universal |
| P·7 | retirar lo que sobre de sql-parse.ts + trinquete | ⛔ NO PROCEDE TODAVÍA, y el censo lo dice: los 9 exports siguen en uso (scanTableRefs 40 · extractDmlTarget 24 · rewriteDottedFromRefs 21…). Depende de P·4·bis, no de una limpieza |
⭐ P·3 es el corazón y no se puede saltar. Es el patrón que este repo ya sabe que funciona —los diferenciales en frío son la herramienta que encuentra— y aquí tiene la propiedad rara de que las dos implementaciones existen a la vez: se puede comparar antes de cambiar nada.
⚠️ P·6 es la razón de todo esto. Si el approach entero se hiciera y P·6 no entrase, habríamos comprado elegancia; con P·6, se deja de perder datos.
6·bis · 🏁 P·1, corrida — y encontró un tercer desenlace
lib/warehouse/query/plan-corpus.ts (31 sentencias, con sus hechos esperados) +
scripts/bridge/p1-capturar-fixtures.ts → lib/warehouse/query/plan-fixtures.json.
29 planes + 2 fixtures negativas, y --trinquete en verde: las fixtures son
estables entre corridas, que es lo único que las hace servir.
⭐ Lo que el plan demuestra, ya capturado
| Caso | Lo que devuelve | Por qué importa |
|---|---|---|
literal-que-parece-tabla | 'Project [FROM ventas AS s#N] | ⭐⭐ el literal es una expresión, no una relación. El fallo de M2c —reescribir dentro de una cadena— no es representable en un plan |
delete-igualdad-negada | 'DeleteFromTable NOT ('nombre = x) | ⭐⭐ el predicado es legible y ya viene normalizado a NOT(=) — exactamente la forma del corruptor de C3 ⇒ P·6 es implementable |
cte-simple · cte-citada | raíz CTE [x] / CTE [mi cte] | los nombres de CTE los declara el propio plan, citados incluidos |
tvf-mezclada | separa read_parquet de ventas | el vector H5, nativo |
insert-select | 'InsertIntoStatement 'UnresolvedRelation [destino], [__required_…] | fuente y destino por separado: el único obstáculo que dejaba INSERT … SELECT fuera |
nombre-inexistente | lo nombra igual | Index sigue siendo quien decide |
⭐⭐ El hallazgo: EXPLAIN puede tener éxito y devolver un ERROR como texto
WITH diets AS (…) DELETE FROM diets —el bug silencioso de la semana— hoy responde:
Error occurred during query planning:
[INTERNAL_ERROR] Unexpected table relation: Project [1 AS id#N] +- OneRowRelation
Dos cosas de golpe:
- Ese shape ya no es silencioso: Spark 4.1.2 lo rechaza. Bien.
- ⭐⭐ Pero hay un TERCER desenlace que el approach no había previsto: ni
ParseException(la sintaxis era válida) ni plan. Y es el peligroso: un lector que ahí devolviera «sin hechos» dejaría pasar una query que aparenta no tocar nada — fail-OPEN, lo contrario de lo que §5·2 promete. ⇒ Queda como fixture negativa a propósito: el lector de P·2 tiene que rechazarla, y hay un test que lo exige.
⚠️ Y el trinquete falló antes de acertar
La primera versión normalizaba los ids de expresión del plan pero no del texto del
error, y el error de planificación incrusta uno (id#22873 → id#23855). Daba diff en
cada corrida. ⇒ Se normalizaba la mitad del fichero. Es la sexta vez en la jornada
que un instrumento falla antes que el sistema, y deja regla:
Si un artefacto se congela para compararlo, hay que normalizar TODOS sus campos —
no sólo el que se tenía en la cabeza.
Lo medido de paso
- El plan parsed sí lleva ids de expresión en los ALIAS (
1 AS n#22451) — 4 de 29 fixtures. Se normalizan a#N. - Latencia del
EXPLAIN: ~508-576 ms de mediana sobre este corpus (sentencias más variadas que las de P·0, que dieron 373 ms). Confirma el orden de magnitud y refuerza que la caché de §5·3 no es opcional.
6·ter · 🏁 P·2, cerrada — el lector, y 37/37 sin tocar el cluster
lib/warehouse/query/plan-reader.ts + plan-reader.test.ts. Cero red, cero motor.
⭐ La regla de seguridad: no reconocerlo todo, sino lo que mete datos
Un plan real trae decenas de nodos (Project, Filter, Sort, Aggregate, Window…).
Exigir conocerlos todos rompería el editor en la primera query rara y no compraría
seguridad: un Sort no mete datos. La frontera se pone donde importa:
Un nodo que puede INTRODUCIR DATOS tiene que estar en la lista blanca. Todo lo que
huela a relación, tabla o identificador y no esté ⇒forma-desconociday la query se
rechaza. Lo que sólo transforma se atraviesa sin mirarlo.
Y hay un test que lo fija en las dos direcciones: rechaza un
'UnresolvedInventadoRelation, y atraviesa un 'Sort que nunca se enumeró.
Lo que el lector aprovecha del plan, y que ninguna heurística tenía
| Cómo | |
|---|---|
| El destino de escritura | ⭐ lo marca Spark, no la posición en el texto: la relación anotada con __required_write_privileges__. extractDmlTarget lo deducía de dónde caía el token |
| El privilegio | del mismo sitio. Antes no lo sabía nadie |
| Los nombres de CTE | el plan los declara en su propio nodo (CTE [a, b]) y sin comillas ⇒ el problema del citado desaparece: no hay nada que des-citar |
El prólogo WITH … UPDATE | ⭐⭐ el UpdateTable cuelga del nodo CTE como su cuerpo. El prólogo deja de ser un problema de posición en una cadena y pasa a ser un nodo más del árbol — que es, en una línea, por qué existe este approach |
| Las funciones de tabla | nodo propio (UnresolvedTableValuedFunction) ⇒ el vector H5 sin las 95 líneas de scanTableRefs |
| El destino de un CTAS | va como UnresolvedIdentifier, no como relación — la misma distinción que W4 tuvo que descubrir midiendo |
Y el instrumento se verificó antes de fiarse de él
37/37 a la primera es sospechoso, así que se rompió el lector a propósito (quitando la exclusión de CTEs) y el suite pasó a 3 rojos, exactamente los tres casos de CTE. Un test que no puede fallar no es un test.
Los 9 provisional, promovidos por MEDICIÓN
Los nueve casaron ⇒ pasan a estandar, y la promoción se anota como lo que es: por
medición, nunca por conveniencia. ⚠️ Y al promoverlos quedó texto rancio —tres porque
seguían diciendo «Provisional: hay que ver…» con el campo ya en estandar—: corregido.
Es la misma clase de deriva que el repo persigue en los docs, dentro de un fichero.
6·quater · 🏁 P·3, cerrada — las dos implementaciones, comparadas antes de cambiar nada
scripts/warehouse/p3-diferencial-lector.ts (npm run check:plan-diferencial), en
frío. Reproduce el camino viejo tal y como lo compone producción —route.ts:210
y :352, duck-plan:207-208— porque medir con «lo que me venía bien» es §12·19, la
regla que ya costó una regresión.
29 comparados · 7 discrepancias · las 7 estudiadas · 4 empates que esconden pérdida.
⚠️ Y el comparador mintió primero — contra el lector nuevo
La 1ª versión normalizaba el lado nuevo siempre al último segmento, así que
information_schema.tables y el destino main.default.diets salían como discrepancia
cuando el lector tenía la respuesta más completa, no otra. Dos de nueve eran mías.
⇒ Un token viejo casa ahora con cualquiera de las dos formas legítimas (coordenada
entera o último segmento), porque rewriteDottedFromRefs colapsa unas y conserva otras.
Séptima vez en la jornada que el instrumento falla antes que el sistema.
⭐⭐ La familia grande (6 de 7): el destino de un DML no era una «referencia»
scanTableRefs sólo mira tras FROM/JOIN. Un UPDATE x no tiene FROM ⇒ el
camino viejo no ve ninguna tabla, y el destino viaja por otro carril
(extractDmlTarget). El lector unifica: el destino es una referencia, y encima
anotada con su privilegio.
⭐⭐ Y midiendo salió lo que lo convierte en hallazgo:
delete-simpleSÍ empata.
¿Por qué? PorqueDELETE **FROM** xlleva la palabraFROMy el escáner la pilla de
rebote. ⇒ La cobertura del destino dentro debaseTablesdependía de si el verbo
usa la palabra FROM. Eso es un accidente, no un diseño — y es la lección de M6 otra
vez: el verbo no es el eje.
Veredicto: no miente ninguno. Gana el nuevo por unificar.
⚠️ CORRECCIÓN (P·4·0, al abrir el código). Aquí se escribió «el viejo se
descompone por accidente», y es falso:junction.ts:1650lo hace a propósito y lo
explica —«INSERT INTO ventas VALUES (…)yUPDATE ventas SET …no tienenFROM, así
que el plan de lectura no ve NINGUNA tabla»— y por eso añade el destino aentries
para que quede bajo la misma validación de tenencia. La cobertura está completa y
es deliberada.Lo que sí queda en pie, y es lo que vale: el CAMINO por el que el destino llega a la
gobernanza depende del verbo (porscanTableRefssi la sentencia lleva la palabra
FROM, porwriteTargetsi no). Dos carriles para la misma pregunta. El lector deja
uno.
⭐ La que decide (1 de 7): cte-homonima-del-destino
El viejo extrae destino=diets y habría seguido adelante con una sentencia que Spark
no sabe planificar: abriría la transacción del ledger y fallaría en el motor. El lector la
rechaza antes de tocar nada. Miente el viejo. Es el caso que justifica la guardia
fail-closed.
Los 4 empates que esconden una pérdida
select-cualificado · select-backtick · nombre-inexistente · multi-namespace:
coinciden sólo porque el viejo tiró la coordenada. El lector la conserva — que es lo
que hace gobernable una query multi-namespace.
El trinquete sabe ponerse rojo
Roto a propósito el extractor de funciones de tabla → 3 discrepancias nuevas, las 3 de TVF, exactas. Salida 1.
6·quinquies · 🏁 P·4·0 · sobre QUÉ SQL se le pide el plan — y por qué el approach se equivocaba
scripts/bridge/p4-0-gate-sobre-que-sql.ts, salida 0.
§3 daba por hecho que el plan se pide sobre el SQL del usuario. Al abrir runQuery
para implementar P·4 apareció que eso sería una regresión de gobernanza, y lo dice el
propio código (junction.ts:1628):
«un escaneo paralelo no ve las bases que viven DENTRO del cuerpo de una vista, así que
la gobernanza validaría un conjunto de tablas distinto del que el motor va a leer. Un
SELECT * FROM mi_vistaautorizaría, de hecho, tablas que nadie miró.»
Medido, con su control:
| Se pregunta sobre | Referencias |
|---|---|
| el SQL del usuario | [zonas_altas] ⚠️ — sólo la vista: la tabla de dentro quedaría sin gobernar |
| ⭐ el SQL que se ejecuta | [main.default.enclosure_zones] ✅ — la base, con coordenada, y la vista excluida por ser una CTE |
⇒ DECIDIDO: el plan se pide sobre el SQL QUE SE VA A EJECUTAR. Es lo único que cumple
literalmente el invariante que el repo ya exigía: la gobernanza ve el mismo conjunto que
el motor lee. Y de regalo trae la coordenada, que el baseTables de hoy tira.
⭐⭐ Y con ello P·4 cambia de forma: auditor, no sustituto
El plan devuelve nombres; entries/bindings necesitan datasetId, fqn,
schema y workspaceId, que salen de la resolución contra el catálogo que hace
buildDuckReadPlan. ⇒ No se puede intercambiar sin más.
Lo que sí se puede —y es mejor— es que el plan audite:
Si el motor va a leer una referencia que la gobernanza no vio, se rechaza.
| Riesgo de regresión | ⭐ cero: sólo añade una comprobación, no cambia ninguna decisión existente |
| Qué cierra | la propiedad de seguridad de verdad: nada de lo que el motor lea escapa a la gobernanza — y ya no por construcción del escáner, sino verificado contra el motor |
| Qué habilita | cuando el auditor lleve tiempo sin discrepar, la sustitución deja de ser un salto de fe |
⚠️ Y sigue en pie lo que P·3 anotó: al unificar, el destino aparece en referencias
⇒ el auditor debe separar LEER de ESCRIBIR con privilegioRequerido, o una tabla
escribible-no-legible empezaría a fallar.
Lo que además queda a la vista
junction.ts:1707 tiene ocho líneas de cirugía de cadenas para des-cualificar el
destino que extractDmlTarget devuelve «tal como lo escribió el usuario» —con su
regresión de producción documentada al lado (main.default.main.default.diets)—. El
plan devuelve destino: ['main','default','diets'] ya partido: el nombre lógico es
el último elemento. Ese bloque entero sobra.
6·sexies · 🏁 P·4 · el auditor — y lo que dice el estándar de raíz
⭐⭐ El problema tiene nombre: parser differential
No es una analogía nuestra. Que un control de seguridad parsee la entrada distinto de como la parsea quien la ejecuta es una clase de vulnerabilidad catalogada: es el mecanismo de los 1.207 bypasses de WAF de WAFFLED (2026), de CVE-2026-52747 en ModSecurity (CVSS 8.6) y del bypass de AWS WAF con sintaxis JSON de Claroty.
Nuestros escáneres de texto son exactamente eso, y P·3 midió que discrepan del
motor en 7 sitios. ⇒ De raíz, el estándar dice eliminar el segundo parser. Dejar
el auditor como destino permanente sería institucionalizarlo.
Dónde autoriza la industria — y por qué se audita el SQL EJECUTADO
| PostgreSQL | comprueba privilegios tras la reescritura: «debido a la reescritura se acceden otras tablas que las de la consulta original» |
| Trino | checkCanSelectFromColumns durante el análisis, con catálogo/esquema/tabla ya resueltos |
| Spark / UC | al resolver la relación — y anota el privilegio en el nodo, que es lo que P·0 midió |
⇒ P·4·0 no fue un apaño: es el estándar.
Y por qué AUDITA antes de decidir
Envoy tiene shadow rules, Istio dry-run, OPA dry-run, los WAF monitor mode: es la
práctica estándar para desplegar un control de autorización. Con una diferencia a
nuestro favor: ellos ponen en sombra la decisión (permitir/denegar); aquí se pone
en sombra la entrada de la decisión — qué objetos se tocan. No se prueba si la
política acierta: se prueba si estamos mirando los objetos correctos.
⭐ El criterio de salida, en el CÓDIGO
CRITERIO_DE_SALIDA vive en plan-auditor.ts, no sólo en este documento, porque un
modo sombra sin criterio de salida se queda de sombra para siempre — que es el fallo
clásico del patrón:
① CERO discrepancias `no-vista` en 500 queries reales consecutivas
② ≥ 14 días de tráfico (para que entren vistas, information_schema, multi-namespace…)
③ las discrepancias que queden, TODAS explicadas por escrito — una sin explicar bloquea
Cumplidos ⇒ enforce, y después P·4·bis retira el segundo parser.
Lo entregado
PLAN_AUDITOR=off(por defecto) ·shadow·enforce.- ⭐ En sombra NO se espera: medir no puede costarle latencia al usuario, y un fallo
del auditor no puede ser un fallo de la query — sería convertir un instrumento de
medida en un modo de fallo. En
enforcesí se espera, y decide antes de ejecutar. - Caché global por hash del SQL, segura porque el plan parsed es función pura del texto (§3·3). Convierte los ~500 ms en coste de primera vez.
no-medidoeilegibleno son aprobados: son la ausencia de veredicto, y se dicen.
Los gates
12 tests en frío —verificados en rojo: neutralizada la comprobación de cobertura,
falla exactamente el test de no-vista—. Y en vivo con PLAN_AUDITOR=shadow:
W3·b (escritura) → [auditor:shadow] acuerdo · refs=1 · GATE VERDE
W4 (CREATE TABLE) → [auditor:shadow] acuerdo · refs=1 · GATE VERDE
⇒ Cero regresión, que era la propiedad que justificaba empezar por el auditor.
6·septies · 🏁 P·6 · el guardián del DELETE — y lo que destapó al probarlo
lib/warehouse/query/delete-null-guard.ts + DELETE_NULL_GUARD = off | warn | reject.
⭐ Sólo es posible porque el motor NORMALIZA
Las tres formas peligrosas se escriben distinto y el plan las colapsa en una
(p6-0-formas-del-predicado.ts):
s <> 'a' · s != 'a' · NOT (s = 'a') → NOT ('s = a)
Detectarlo sobre el SQL del usuario habría exigido reconocer tres sintaxis con sus espacios, comentarios y literales — la séptima repetición del fallo de la semana. Sobre el plan es una sola forma, y la escribe una máquina.
Lo que NO marca, y está medido
| Forma | Plan | |
|---|---|---|
s <> 'a' AND s IS NOT NULL | (NOT ('s = a) AND isnotnull('s)) | ✅ ya protegida |
NOT (s <> 'a') | NOT NOT ('s = a) | ✅ doble negación |
NOT (s > 'a') | NOT ('s > a) | ✅ la negación no era el problema |
UPDATE con el mismo predicado | — | ✅ se comporta bien |
⚠️ Y una precisión que costó pensarla: id <> 1 AND s = 'b' conservó la fila NULL al
medirlo — pero sólo porque el NULL estaba en s, no en id. Si id pudiera ser
NULL, ese DELETE lo borraría. ⇒ marcarlo es CORRECTO: confundir «no pasó con estos
datos» con «no puede pasar» es cómo se pierde un dato.
El defecto por defecto es reject, y es una decisión
El defecto destruye datos hoy, en silencio. Un modo permisivo por defecto sería saberlo y dejar que siga pasando. El coste del rechazo está acotado y es reversible: un mensaje con el arreglo exacto, que además es lo que la sentencia ya significaba.
🟡 Y el gate destapó lo que BLOQUEA su activación
p6-gate-guardian-delete.ts, con su control:
① peligroso, guardián ON → RECHAZADO · dato INTACTO (3 filas) ✅
③ peligroso, guardián OFF → quedan 1 de 3: la fila NULL se PERDIÓ ✅ el defecto es real
② el rodeo, guardián ON → ⛔ AnalysisException: Cannot delete from table…
⭐ ③ es lo que da valor a ①: sin él demostraría que la puerta rechaza algo, no que rechaza algo que hace daño.
Y ② no es un fallo del guardián. Aislado en p6-b-diag-rodeo.ts, sólo falla UNA
combinación — y es exactamente la que la puerta produce en cada escritura:
| destino cualificado + CTE homónima + rodeo | ✅ |
| destino pelado, sin prólogo, + rodeo | ✅ |
| ⛔ destino PELADO + CTE homónima + rodeo | FALLA ← lo que manda planSql |
| destino pelado + CTE homónima, sin rodeo | ✅ …y borra el NULL en silencio |
⭐⭐⭐ La reescritura de la puerta hace IMPOSIBLE la forma segura y POSIBLE la
peligrosa. Y la forma que produce —destino pelado con una CTE homónima— es
literalmente la del bug silencioso de la semana, que no estaba arreglado: la puerta
lo genera solo, en cada escritura.
⇒ P·5 asciende a prerrequisito de P·6. Activar el guardián ahora dejaría al usuario
sin salida: le diríamos que añada AND col IS NOT NULL y el sistema lo rechazaría. El
arreglo es retirar el prólogo con el USE por sesión que B9 ya dejó probado.
⇒ Por eso el gate sale 1, no 0: verde a medias no es verde. Y por eso
DELETE_NULL_GUARD tiene reject por defecto en el código pero no se pone en el
entorno todavía (BLOQUEADO_POR lo declara).
6·octies · 🏁 P·5·a — la tercera ruta del mismo fallo, y P·6 queda verde
El bloqueo de P·6 no era del guardián ni del motor: era qualifyWriteTarget
devolviendo el SQL intacto en silencio, por tercera vez y por una ruta nueva.
1ª (W3·a·0) el ancla `^` no veía el prólogo `WITH …` → SQL intacto, en silencio
2ª (§12·19) los gates medían el nombre PELADO → la regresión llegó a prod
3ª (P·5·a) el destino CUALIFICADO ya venía COLAPSADO → SQL intacto, en silencio
El mecanismo, exacto: rewriteDottedFromRefs reescribe todo FROM a.b.c → FROM c,
y un DELETE FROM lleva la palabra FROM. Así que cuando el usuario cualifica
(DELETE FROM main.default.t), para cuando qualifyWriteTarget corre el SQL ya dice
t mientras el token declarado sigue diciendo main.default.t. No casaban ⇒ return sql, sin decir nada.
⛔⛔ Y el daño no era cosmético: el destino se quedaba pelado junto a la CTE
homónima que el propio plan antepone — la única combinación que hace fallar
AND col IS NOT NULL, o sea que hacía imposible el único rodeo seguro del corruptor
de C3. El guardián de P·6 no se podía activar porque el arreglo que recomienda estaba
prohibido por nuestra propia reescritura.
El arreglo: probar las dos formas legítimas del token —entero y último segmento—, que es la misma regla que el comparador de P·3 tuvo que aprender. Y que el caso «ninguna casó» deje de ser mudo: se registra.
Los gates
- Regresión en frío en
w3-write-target-dialect.test.ts— el fichero cuya cabecera ya describía este peligro… para el destino pelado. El nuevo caso usa el cualificado. Verificado en rojo revirtiendo el arreglo. - ⭐
p6-gate-guardian-delete.tspasa a VERDE:
① peligroso, guardián ON → RECHAZADO · dato INTACTO (3 filas)
② el rodeo, guardián ON → PASA y borra SÓLO la fila que debía (quedan 'a' y NULL)
③ peligroso, guardián OFF → quedan 1 de 3: la fila NULL se PIERDE
- Sin regresión: W3·b y W4 verdes, 528 tests en
lib/compute+lib/warehouse.
⚠️ Y una lección del propio proceso
La primera verificación de este arreglo dio un falso verde: la reversión con la que
iba a comprobar que el test podía fallar no llegó a aplicarse (un replace de
varias líneas que no matcheó), y el test «pasó sin el arreglo». Fue el octavo
instrumento que miente en la jornada — esta vez, el instrumento que verificaba al
instrumento. ⇒ una reversión de prueba tiene que confirmar que ha revertido, no
suponerlo.
6·nonies · 🏁 P·5·b·1 — el puente fija el namespace · ⛔ y el lado Node queda bloqueado
Lo hecho, y verificado en frío
services/spark-bridge/sesiones.py emite USE <DEFAULT_NAMESPACE> al crear la sesión,
en try: si el namespace no existe para el prefijo de ese inquilino, la sesión nace
igual —cambiar un prólogo incómodo por una caída sería un mal negocio— y se dice.
⚠️ El código del puente vive en un ConfigMap, no en una imagen: desplegar es
kubectl create configmap --dry-run | kubectl apply + rollout. Hecho.
p5b-gate-namespace-de-sesion.ts, salida 0, y sobre sesión FRÍA —lo prueba la
latencia de 3.743 ms, no una suposición sobre el estado del pool—:
current_catalog="carbon" current_schema="main.default"
① el namespace viene puesto ......... SÍ ✅
② un nombre PELADO resuelve ......... SÍ ✅
③ la coordenada completa sigue ...... SÍ ✅ (sin regresión)
⛔ Y por qué el lado Node NO se toca
Al ir a implementarlo apareció la divergencia que el propio comentario del cambio
acababa de advertir: Node tiene LAKEHOUSE_NAMESPACE=main.test y el puente quedó en
main.default. Censo de Index:
84 main.default ← donde vive el dato
20 (NULL ⇒ usa el fallback) ← Node los resuelve a main.test
9 main.test
1 main.my_first_project
Tres cosas de golpe:
- Hay CUATRO namespaces, no uno. Un default de sesión sólo puede cubrir uno ⇒ el prólogo tiene que seguir existiendo para los otros 30 datasets. P·5·b·2 no es «quitar el prólogo», es quitarlo donde sobra.
- ⚠️ El fallback de Node no apunta a donde está el dato.
main.testcubre 9 datasets;main.default, 84. Es la misma clase de divergencia de cintura que F3 ya corrigió una vez enfqn.ts. - Los 20 con namespace NULL resuelven hoy por ese fallback. Cambiar
LAKEHOUSE_NAMESPACEmueve dónde se los busca — y el censo de deriva contaba 20 fantasmas. Coincidencia sugerente que hay que medir antes de tocar nada.
⇒ Decisión del owner, no de implementación: cuál es EL namespace por defecto. Con esa respuesta, P·5·b·2 es acotado (saltar el prólogo sólo cuando el namespace del dataset coincide con el de la sesión).
⭐ Mientras tanto no hay riesgo: el USE del puente es aditivo y compatible —la
coordenada completa sigue resolviendo (③)— y Node sigue emitiendo el prólogo como
siempre.
6·decies · ⭐⭐ El «namespace por defecto» no es lo que yo creía — y P·5·b·2 cambia de forma
La pregunta del owner —«¿poner main.default significa que todo usuario tiene que
regirse por catálogo main, esquema default? Databricks y Snowflake me dejan crear y
renombrar»— destapó un error de encuadre mío, y la investigación lo resuelve.
① Un default es una CADENA DE FALLBACK, no una restricción
| Cómo resuelve un nombre sin cualificar | |
|---|---|
| Databricks | USE CATALOG (sesión) → spark.databricks.sql.initial.catalog.namespace (cluster) → default del WORKSPACE |
| Snowflake | ⭐ DEFAULT_NAMESPACE es una propiedad del USUARIO: ALTER USER x SET DEFAULT_NAMESPACE = db.schema |
⇒ El default sólo dice qué significa un nombre pelado. Nadie queda encerrado: la coordenada completa sigue funcionando siempre (verificado en nuestra sonda ③ de P·5·b·1). Y el de Databricks es por workspace — exactamente nuestra unidad de inquilino.
② Crear sí; renombrar, ahí está el límite del formato
| Crear | Renombrar | |
|---|---|---|
| Unity Catalog | catálogos y esquemas ✅ | esquema ✅ · catálogo ⛔ por SQL (crear otro y mover los activos) |
| ⛔ Iceberg REST (lo nuestro) | namespaces ✅ | NO EXISTE: el spec tiene renameTable y renameView, no renameNamespace (issue apache/iceberg #13023, abierto) |
⇒ No es una decisión nuestra: es del formato. Lo que sí se puede es lo que hace UC —
crear el nuevo y mover —, y en Iceberg eso es renameTable en lote sin mover
bytes. Se declara como limitación en el contrato publicado, con su motivo.
③ ⭐⭐ Y el error de encuadre: son DOS defaults, no uno
| Qué decide | Cuándo | |
|---|---|---|
LAKEHOUSE_NAMESPACE (Node) = main.test | dónde se COLOCA un dataset cuando nadie lo dijo | escritura |
DEFAULT_NAMESPACE (puente) = main.default | qué significa un nombre pelado al leer | lectura |
No tienen por qué coincidir — Databricks también los separa. Tratarlos como el mismo valor fue lo que me hizo leer una «divergencia» donde hay dos conceptos.
Pero sí hay una regla dura: el default de LECTURA tiene que ser donde están las tablas de quien pregunta. Y ahí es donde un valor global se rompe — hoy funciona porque hay un solo inquilino con datos.
⇒ La forma de P·5·b·2
- El default de lectura pasa a ser POR WORKSPACE, no una variable global — igual que
el workspace default catalog. El puente ya recibe el
workspaceIdy ya crea una sesión por inquilino: es el sitio natural, y no hace falta mecanismo nuevo. main.defaultse queda como valor de arranque declarado TRANSITORIO, no como constante.- El prólogo se salta cuando el namespace del dataset coincide con el de la sesión de ese inquilino — condición local y siempre correcta, que no depende de elegir un namespace universal. Los otros 30 datasets conservan el prólogo, y está bien.
LAKEHOUSE_NAMESPACEno se toca: es otra pregunta (dónde nacen los datasets sin coordenada) y afecta a los 20 con namespace NULL — que el censo de deriva sugiere que podrían ser los 20 fantasmas. ⚠️ Hay que medirlo antes de moverlo.
6·undecies · 🏁 P·5·b·2·a — el default de lectura, por workspace
Deja de ser una variable global y pasa a ser del inquilino, que es lo que hace Databricks con su workspace default catalog.
⭐ No hizo falta inventar dónde vive
Index ya modela la jerarquía: catalogs(workspace_id, name) → schemas(catalog_id, name), con datasets.schema_id poblado 114/114. ⇒ el namespace Iceberg no es un
concepto paralelo: es esa pareja escrita con un punto (main.default,
main.my_first_project…). Y el valor declarado va en workspaces.settings, que es
JSONB ya existente: cero migraciones.
⭐ Declarado, no derivado — y el porqué importa
La tentación era derivarlo: «el namespace donde este workspace tiene más tablas». Hoy
daría main.default (84 de 114) y funcionaría. Pero una derivación cambia sola: el
día que alguien cargue 200 tablas en otro esquema, el significado de todos los nombres
pelados del inquilino se voltea sin que nadie lo decida. Un default que se mueve solo
es peor que uno equivocado, porque no se puede razonar sobre él.
⭐⭐ Por qué el ns SÍ viaja en el token, si el prefijo NO
engines/spark.ts lleva escrito que «el prefijo NO viaja: el puente lo DERIVA; mandarlo
sería un segundo sitio donde equivocarse». Parece contradecirse, y no:
| prefijo | se DERIVA del workspaceId — las dos partes pueden calcularlo ⇒ transportarlo crea una segunda fuente con la que divergir |
| namespace | vive en Index, y el puente no tiene base de datos, a propósito ⇒ no hay segunda fuente: o viaja, o el puente aprende a hablar con Index — la dependencia que su diseño evita |
⚠️ Y no añade superficie de ataque: quien tenga el secreto ya puede acuñar tokens para
cualquier inquilino, la cara sigue comprobando que la tabla no contradiga el prefijo,
y el ns no autoriza nada — sólo decide qué significa un nombre pelado.
El reparto de capas
Lo resuelve la puerta (que conoce Index) y viaja en el GovernanceContext hasta el
motor, igual que writeTarget. El adaptador no lo consulta ni lo deduce: sigue
ciego, que es la propiedad que hace intercambiables los motores.
Los gates
① sin `ns` en el token → cae al GLOBAL ......... "main.default" (3761 ms, sesión fría)
② con `ns=main.test` → lo adopta ............. "main.test" (706 ms)
⭐ …y sin recrear la sesión — 706 ms ⇒ CALIENTE
③ volviendo al global → lo re-emite ........... "main.default" (623 ms)
④ `ns` inválido → se IGNORA ............. "main.default" (102 ms)
⭐ ② es el que evita un coste escondido: el namespace es estado mutable de la sesión, no configuración de catálogo. Tirar la sesión para cambiarlo costaría el 47× de una fría por algo que se arregla con una sentencia.
⭐ Y ① es lo que permitió desplegar el puente primero y solo: un token sin ns sigue
siendo válido, así que los dos despliegues no tienen que ser simultáneos.
Sin regresión: W3·b verde · P·6 verde · 528 tests · tsc con los 4 preexistentes.
7 · Los costes, declarados
| ⚠️ 373 ms por sentencia NUEVA | mitigado por la caché (§5·3) a coste de primera vez. Sin medir todavía cuánto cae en uso real — es lo primero que P·1 debe instrumentar |
| ⚠️ La puerta depende del cluster para reconocer | ⭐ pero hoy ya depende: elegirMotorDeLectura devuelve spark o lanza. C no añade un acoplamiento: hace explícito el que ya existe. Se volverá real el día que M1 traiga un motor de un nodo — y ese día hay decisión que tomar, no antes |
| ⚠️ El formato del plan puede cambiar entre versiones de Spark | es exactamente lo que el bundle de C5 guarda: c0-pinneo.ts detecta la deriva del motor, y las fixtures de P·1 la detectan en el formato |
| ⚠️ Sigue siendo extracción de texto | de texto de máquina, regular y versionado. Se reduce el riesgo; no se elimina |
8 · Lo que este documento NO decide
| Si algún día habrá parser propio | si M1 trae un motor de un nodo, C deja de valer para las queries que no van a Spark. Entonces se reabre — con B (dt-sql-parser) como candidato, ya investigado |
| La reescritura de vistas | sigue siendo nuestra y no se toca aquí |
Si el DELETE se rechaza o se reescribe | P·6 lo mide y lo propone; la elección es del owner, porque rechazar rompe queries legítimas |
| Substrait / plan común | fuera: la federación salió del norte |
9 · Lo que ya está construido y aquí se reutiliza
| 🏁 El corpus C2 + C3 | 114 + 16 sondas con esperados autorizados. Es la suite de aceptación del lector, y no hubo que construirla |
🏁 ident.ts (C1) | el citador único: la mitad impresora que un parser no reemplaza |
🏁 B9 · USE | lo que permite dejar de reescribir |
| 🏁 El contrato + el bundle (C4/C5) | lo que detecta que el motor —y con él el formato del plan— ha derivado |
Ninguna de las cuatro se hizo para esto. Es la propiedad que tiene ir cerrando piezas
con gate: la siguiente sale más barata que la anterior.
6·duodecies · 🏁 P·4·bis·0 — el criterio, por fin evaluable
⚠️ El hueco que P·4 dejó, cometido por quien lo había advertido
P·4 escribió CRITERIO_DE_SALIDA en el código —bien— pero no el instrumento para
comprobarlo: el veredicto iba a console.log, y en un runtime serverless eso es
efímero. Sin contador ni historial, el criterio no se puede evaluar ⇒ la sombra se
vuelve permanente por omisión. Es exactamente el fallo clásico del patrón, y lo cometió
el mismo documento que lo advierte dos secciones más arriba.
Cómo se cierra
- Sólo se persiste el DESACUERDO. Un acuerdo no es información —son la inmensa mayoría— y una fila por query costaría más que el propio auditor. ⇒ el criterio se evalúa por la AUSENCIA de discrepancias en una ventana, no contando éxitos.
- En
access_events, sin tabla nueva ni migración: una discrepancia del auditor es un evento de gobernanza —dice que el motor iba a leer algo que la puerta no miró—, así que va donde ya viven los demás, consurface='plan-auditor'. - ⚠️ Con una concesión declarada:
outcometiene un CHECK que sólo admiteok | denied(probado insertando). En sombra no se deniega nada ⇒ se escribeok—la verdad de lo que le pasó a la petición— y el hallazgo va endenied_reasoncon el prefijo del modo. Un tercer valor (divergence) sería más limpio y exige migración; se anota en vez de forzarlo. - Registrar nunca puede tumbar una query — misma regla que hace que el auditor no espere en sombra: un instrumento que se vuelve modo de fallo deja de ser instrumento.
⭐ Y la tercera condición, que es la que impide un aprobado vacío
p4bis-criterio-de-salida.ts evalúa las tres por separado, diciendo cuál falta:
① sin no-vista ............... SÍ ✅
② ventana de 14 días ......... NO ⏳ (llevan 0.0)
③ hay exposición real ........ SÍ ✅ (3.961 eventos de tráfico)
⏳ TODAVÍA NO.
⭐ ① pasa VACUAMENTE —cero discrepancias porque cero mediciones— y aun así el veredicto es correcto, porque ② cuenta los días desde el primer evento del auditor, que nunca ha escrito. Cero discrepancias con cero tráfico no es evidencia: es silencio, y el instrumento tenía que saber distinguirlo.
⇒ P·4·bis deja de ser «cuando toque» y pasa a tener una condición comprobable con un comando. Que era lo único que le faltaba para no quedarse en sombra para siempre.
6·terdecies · ⛔⛔ P·4·bis·1 — el criterio era INSATISFACIBLE, y el latido lo arregla
2026-08-11, tarde. Al correr el instrumento de §6·duodecies con la cabeza fría
apareció que no cerraba el hueco que decía cerrar. Tres defectos, no uno.
El fallo, en dos líneas de código que no se miraron juntas
plan-auditor.ts registrarDiscrepancia: if (v.estado === 'acuerdo') return; ← no escribe si acierta
p4bis…ts:86 dias = ahora − (PRIMER evento del auditor) ← la ventana arranca al fallar
⇒ Con la sombra funcionando PERFECTO, el auditor no escribe nunca, la ventana no arranca nunca y el criterio no se cumple JAMÁS. Es insatisfacible exactamente en el caso que se quería alcanzar. El instrumento escrito para que la sombra no fuera eterna la hacía eterna, y lo hizo pasando por el mismo sitio dos veces: §6·duodecies advierte del fallo clásico del patrón y lo comete en la línea 86.
Y otros dos del mismo tamaño, que sólo se ven al tirar del primero
| ⓪ | un auditor off y uno shadow de acuerdo con todo producían la misma salida exacta. El gate no podía decir si estaba midiendo |
| ③ | la exposición se medía con el tráfico del sistema —que no dice nada sobre si el auditor miró algo— y los 500 queries del criterio no los evaluaba nadie: estaban declarados en CRITERIO_DE_SALIDA y ningún lector los leía |
⭐ El latido
Un evento barato y periódico: «estoy mirando, y llevo N veredictos». Al primer
veredicto de cada runtime —que en serverless es la señal de que ese runtime existe— y
luego cada LATIDO_CADA = 50. No una fila por query: el coste es el argumento.
⭐⭐ Y lleva DELTAS, no acumulados. Con acumulados, un runtime que emite 1, 50,
100 sumaría 151 por 100 veredictos, y corregirlo obligaría a transportar una identidad
de runtime en la fila para tomar el máximo por cada una. Con deltas la suma de las
filas ES el total, exacta y sin nada que transportar. La aritmética se elige para que
el lector no tenga que ser listo.
⭐ ilegible y no-medido se cuentan APARTE y no suman a ③: un auditor roto en
silencio produciría 500 «mediciones» sin haber medido nada. Aprobar por volumen sin
mirar la calidad del volumen es la misma trampa un nivel más abajo.
El gate, ahora
⓪ el auditor ESTÁ midiendo ... NO ⛔ (sin latidos: la sombra está apagada…)
① sin no-vista ............... SÍ ✅
② ventana de 14 días ......... NO ⏳ (llevan 0.0)
③ 500 queries con veredicto . NO ⏳ (0)
⏳ TODAVÍA NO. Y lo primero que falta es ENCENDER la sombra en producción.
⇒ La diferencia que importa: antes decía «llevan 0.0 días» sin salida; ahora dice qué acción lo desbloquea.
Lo verificado
- 8 tests nuevos (541 en total), y en rojo: revertido el latido a «sólo escribe
si discrepa» —con
grepque confirma que la reversión se aplicó, §12·28— caen exactamente los 4 que fijan el defecto, y los 16 restantes siguen verdes. - ⚠️ Un cambio de código lo destapó el harness: dos escrituras en el mismo tick
dejaban una sola fila, porque el segundo
import()dinámico concurrente no resolvía. En Node el registro de módulos lo deduplica solo, pero pedir la importación en cada escritura sobraba de todos modos ⇒ se memoiza (y el fallo de importación no se cachea: cachear un error transitorio lo vuelve permanente).
⚠️ Y una afirmación del código que estaba MAL MEDIDA
El docblock decía que access_events.outcome «sólo admite ok | denied (probado
insertando)». Falso: la sonda contra la BD desplegada insertó los tres valores
de la migración 20261267 —ok, denied y error— sin error. La decisión de
escribir ok no cambia (una discrepancia en sombra no es un error de la petición), pero
el motivo que la justificaba estaba mal medido, y un motivo falso envejece peor que
una decisión discutible. Corregido en el mismo commit.
9·bis · ⚠️ P·7 · por qué la «limpieza» no toca todavía
Censo, no impresión — grep de cada export de sql-parse.ts fuera de su propio fichero:
scanTableRefs 40 · extractDmlTarget 24 · rewriteDottedFromRefs 21 · splitCtas 16
extractCteNames 14 · ALLOWED_TABLE_FUNCTIONS 8 · extractTableNames 5
extractExplainPrefix 4 · locateDmlCommand 3
Los nueve siguen vivos. Y es coherente: P·4 dejó el plan auditando, no sustituyendo, así que los escáneres siguen siendo quienes deciden. Retirarlos ahora sería quitar el suelo antes de poner el nuevo.
⇒ P·7 depende de P·4·bis, que a su vez depende del criterio de salida del auditor. No es una limpieza aplazada por pereza: es una que no se puede hacer todavía, y decirlo es más útil que inventar un borrado cosmético.
Lo que sí queda hecho: cero escombro temporal — los scripts de diagnóstico
(c3-diag-nulos*, p0b-nombre-malo, p6-0-formas-del-predicado, p6-b-diag-rodeo) se
conservan a propósito: son la evidencia de afirmaciones que estos documentos hacen, y
en esta casa cada hecho lleva el comando que lo demuestra.
10 · Los comandos
kubectl -n spark patch sparkconnectserver carbon-connect --type=merge \
-p '{"spec":{"clusterOperation":{"stopped":false}}}'
kubectl -n spark scale deploy/spark-bridge --replicas=1
kubectl port-forward -n spark svc/spark-bridge 18080:80
export BRIDGE_JWT_SECRET=$(kubectl get secret spark-bridge-auth -n spark \
-o jsonpath='{.data.jwt-secret}' | base64 -d)
npx tsx scripts/bridge/p0-gate-plan-motor.ts # ¿es viable C? 🏁
npx tsx scripts/bridge/p0b-nombre-malo.ts # ¿nombra lo que no existe?
npx tsx scripts/bridge/c2-corpus-lectura.ts # el corpus de lectura
npx tsx scripts/bridge/c3-corpus-escritura.ts # el corpus de escritura
npm run check:carbon-sql-contract # el contrato, en frío