Published

El parser · que parsee el motor — approach de construcción

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 · 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.md sobre 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 propio sql-parse.ts:

exportdecía (08-11)hoy
scanTableRefs405
extractDmlTarget243
rewriteDottedFromRefs218
splitCtas161
locateDmlCommand31

El parser de texto ya está desplazado en el camino de datos, no pendiente de
desplazar. PLAN_MANDA está 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: locateDmlCommand tiene dos consumidores y sólo uno era
legacy — extractDmlTarget descubría (eso lo hace ya el plan), pero
qualifyWriteTarget reescribe 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.ts no es deuda: es la parte de texto que sigue siendo
texto. Retirarla del todo exigiría que PLAN_MANDA no se pueda apagar, y eso es otra
decisión.

⚠️ Y la cabecera dice «manda sustrato-04.md»: el vigente es sustrato-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 del DELETE, 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 un ParseException: 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

SistemaCómo
Spark · Trino · Flinkgramática ANTLR4 (.g4)
Calcite (el de Furnace)JavaCC
PostgreSQL · MySQLbison/yacc
SQLiteLemon
DuckDBlibpg_query — el parser de Postgres, extraído
Vitess — un proxy, como nosotrosgoyacc, 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
Aparser propio a mano⛔ descartada: reinventar mal una rueda de 40 años
Bparser generado en Node (dt-sql-parser, ANTLR4, dialecto Spark)viable, pero puede discrepar del motor: es otra gramática
Cpedirle el plan al motorelegida: 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 verbos6/6 — SELECT · UPDATE · DELETE · MERGE · CTAS · INSERT
efectos secundariosCERO — 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 plansí — verbo, destino y relaciones
sintaxis rotafalla (PARSE_SYNTAX_ERROR)
referencias que no existenlas 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
  1. El destino ya viene partido en namespace — nada que trocear.
  2. __required_write_privileges__: el propio Spark declara qué privilegio hace falta. Es el dato exacto que la puerta necesita para autorizar.
  3. UnresolvedTableValuedFunction distingue nativamente una función de tabla de una tabla — el vector H5 (read_parquet leyendo el object store) para el que scanTableRefs tiene 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 PlanAnalyzed Logical Plan
Resuelve nombres contra el catálogo⛔ no✅ sí
Existe aunque el nombre no exista⛔ no
Lleva 'UnresolvedRelation [partes] + privilegio— (ya resuelto)
Depende del catálogo / del inquilinono✅ sí

Se usa el PARSED, y por tres razones que se refuerzan:

  1. 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.
  2. 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.
  3. ⭐⭐ 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é esHoyCon C
RECONOCERqué verbo, qué destino, qué tablas, qué funcionesscanTableRefs · locateDmlCommand · extractDmlTarget · extractCteNameslo da el plan, al 100%
REESCRIBIRcualificar el destino, anteponer CTEs de vista, expandirqualifyWriteTarget · 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): un USE <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 en main.default), y con él tres de los seis fallos de la semana.
  • 🏁 C1: lib/warehouse/query/ident.ts ya 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 usuarioEl plan de Spark
Quién lo escribeuna persona, adversarialmenteuna máquina
Regularidadningunaalta y estable
¿Puede discrepar del motor?✅ constantementees el motor
¿Versionado?no✅ con el motor ⇒ lo guarda el bundle de C5

Tres reglas, no negociables:

  1. 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.
  2. Sin try { plan } catch { regex }. No hay degradación al camino viejo.
  3. 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·1fixtures: capturar el plan de N sentencias y congelarlas🏁 p1-capturar-fixtures.ts · 29/31 planes + 2 negativas, estables con --trinquete — ver §6·bis
P·2el lector (plan-reader.ts) + sus tipos🏁 37/37 en frío · los 9 esperados provisional promovidos por medición — ver §6·ter
P·3diferencial contra los escáneres viejos🏁 npm run check:plan-diferencial · 7 discrepancias, las 7 estudiadas — ver §6·quater
P·4·0sobre 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·bisretirar el segundo parser — el DESTINO, no una posibilidadabierto 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·6el 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·bNode salta el prólogo cuando el namespace del dataset coincide con el de la sesióndesbloqueado por ·a: la condición es local, no depende de elegir un namespace universal
P·7retirar lo que sobre de sql-parse.ts + trinqueteNO 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.tslib/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

CasoLo que devuelvePor 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-citadaraíz CTE [x] / CTE [mi cte]los nombres de CTE los declara el propio plan, citados incluidos
tvf-mezcladasepara read_parquet de ventasel vector H5, nativo
insert-select'InsertIntoStatement 'UnresolvedRelation [destino], [__required_…]fuente y destino por separado: el único obstáculo que dejaba INSERT … SELECT fuera
nombre-inexistentelo nombra igualIndex 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:

  1. Ese shape ya no es silencioso: Spark 4.1.2 lo rechaza. Bien.
  2. ⭐⭐ 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 nadafail-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#22873id#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-desconocida y 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 escrituralo 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 privilegiodel mismo sitio. Antes no lo sabía nadie
Los nombres de CTEel 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 tablanodo propio (UnresolvedTableValuedFunction) ⇒ el vector H5 sin las 95 líneas de scanTableRefs
El destino de un CTASva 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ónroute.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 FROMel 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-simple SÍ empata.
¿Por qué? Porque DELETE **FROM** x lleva la palabra FROM y el escáner la pilla de
rebote. ⇒ La cobertura del destino dentro de baseTables dependí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:1650 lo hace a propósito y lo
explica —«INSERT INTO ventas VALUES (…) y UPDATE ventas SET … no tienen FROM, así
que el plan de lectura no ve NINGUNA tabla»
— y por eso añade el destino a entries
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
(por scanTableRefs si la sentencia lleva la palabra
FROM, por writeTarget si 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_vista autorizaría, de hecho, tablas que nadie miró.»

Medido, con su control:

Se pregunta sobreReferencias
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óncero: sólo añade una comprobación, no cambia ninguna decisión existente
Qué cierrala 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é habilitacuando 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

PostgreSQLcomprueba privilegios tras la reescritura: «debido a la reescritura se acceden otras tablas que las de la consulta original»
TrinocheckCanSelectFromColumns durante el análisis, con catálogo/esquema/tabla ya resueltos
Spark / UCal 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 enforce sí 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-medido e ilegible no 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

FormaPlan
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 + rodeoFALLA ← 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.cFROM 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.ts pasa 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:

  1. 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.
  2. ⚠️ El fallback de Node no apunta a donde está el dato. main.test cubre 9 datasets; main.default, 84. Es la misma clase de divergencia de cintura que F3 ya corrigió una vez en fqn.ts.
  3. Los 20 con namespace NULL resuelven hoy por ese fallback. Cambiar LAKEHOUSE_NAMESPACE mueve 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
DatabricksUSE CATALOG (sesión) → spark.databricks.sql.initial.catalog.namespace (cluster) → default del WORKSPACE
SnowflakeDEFAULT_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

CrearRenombrar
Unity Catalogcatá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é decideCuándo
LAKEHOUSE_NAMESPACE (Node) = main.testdónde se COLOCA un dataset cuando nadie lo dijoescritura
DEFAULT_NAMESPACE (puente) = main.defaultqué significa un nombre pelado al leerlectura

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

  1. El default de lectura pasa a ser POR WORKSPACE, no una variable global — igual que el workspace default catalog. El puente ya recibe el workspaceId y ya crea una sesión por inquilino: es el sitio natural, y no hace falta mecanismo nuevo.
  2. main.default se queda como valor de arranque declarado TRANSITORIO, no como constante.
  3. 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.
  4. LAKEHOUSE_NAMESPACE no 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:

prefijose DERIVA del workspaceIdlas dos partes pueden calcularlo ⇒ transportarlo crea una segunda fuente con la que divergir
namespacevive en Index, y el puente no tiene base de datos, a propósitono 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 NUEVAmitigado 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 Sparkes 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 textode 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 propiosi 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 vistassigue siendo nuestra y no se toca aquí
Si el DELETE se rechaza o se reescribeP·6 lo mide y lo propone; la elección es del owner, porque rechazar rompe queries legítimas
Substrait / plan comúnfuera: la federación salió del norte

9 · Lo que ya está construido y aquí se reutiliza

🏁 El corpus C2 + C3114 + 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 · USElo 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, con surface='plan-auditor'.
  • ⚠️ Con una concesión declarada: outcome tiene un CHECK que sólo admite ok | denied (probado insertando). En sombra no se deniega nada ⇒ se escribe ok —la verdad de lo que le pasó a la petición— y el hallazgo va en denied_reason con 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 grep que 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 20261267ok, 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