Carbon SQL · el CONTRATO — declararlo y medirlo, que son la misma pieza
Fecha: 2026-08-11. Approach de la decisión del owner «Carbon SQL = Spark SQL
(ANSI), declarado» (A) fundida con el corpus diferencial de ESCRITURA
(P0·1 demapa-computo.md§7).Manda
sustrato-04.mdsobre los hechos.Regla de siempre: lo medido lleva su comando; lo demás va marcado como propuesta.
Todo lo de §1 y §2 está medido hoy contra el Spark que sirve producción.
0 · Por qué A y P0·1 son un solo documento
Son la misma pieza vista por sus dos caras:
| Declarar sin medir | deja un contrato sin oráculo: nadie puede decir si se cumple |
| Medir sin declarar | deja un corpus sin definición de «correcto»: se mide contra lo que el motor haga hoy, que es la definición de tautología |
⇒ El contrato dice qué prometemos; el corpus es cómo se comprueba. Y la industria los publica juntos: Databricks documenta el modo ANSI y su comportamiento; Snowflake versiona el cambio y da una ventana para probarlo.
1 · 🏁 EL PINNEO — medido, no supuesto
scripts/bridge/c0-pinneo.ts contra el Spark que sirve el editor, 2026-08-11:
MOTOR: 4.1.2
| Clave | Valor | Por qué está en el contrato |
|---|---|---|
spark.sql.ansi.enabled | true | errores en runtime en vez de null |
⭐ spark.sql.storeAssignmentPolicy | ANSI | casting implícito AL INSERTAR — gobierna el camino de ESCRITURA, y es independiente del anterior |
⭐ spark.sql.ansi.doubleQuotedIdentifiers | false | ⇒ "x" es un LITERAL DE CADENA, no un identificador |
spark.sql.ansi.enforceReservedKeywords | false | las palabras reservadas ANSI no se imponen |
spark.sql.session.timeZone | GMT | semántica de timestamps |
spark.sql.caseSensitive | false | resolución de identificadores |
spark.sql.ansi.relationPrecedence | false | precedencia de JOIN frente a la coma |
spark.sql.legacy.timeParserPolicy | CORRECTED | parseo de fechas |
spark.sql.sources.partitionOverwriteMode | STATIC | semántica de sobreescritura |
spark.sql.defaultCatalog | carbon | a qué catálogo apunta la sesión |
Y lo que el motor HACE, no lo que dice
Leer la config dice lo que el motor cree. Esto dice lo que hace:
ANSI · cast inválido → LANZA [CAST_INVALID_INPUT]
ANSI · división por cero → LANZA [DIVIDE_BY_ZERO]
ANSI · overflow → LANZA [ARITHMETIC_OVERFLOW]
comillas dobles → devuelve la CADENA "hola" ⚠️
NULL en ORDER BY … ASC → [null, 1, 2] ⇒ NULLS FIRST
⭐ Esto es el contrato. «Spark SQL con ANSI» no era un contrato: era un
puntero. Son un motor con versión, tres interruptores, una zona horaria y un orden
de NULLs — y hasta hoy ninguno estaba escrito en ningún sitio.
2 · ⛔ Lo que el censo destapó: DOS defectos VIVOS
El censo no era una formalidad. Midiendo el pinneo salieron dos, y el primero rompe producción hoy.
⛔ D1 · El plan emite CTEs con comillas dobles hacia Spark
lib/warehouse/query/duck-plan.ts tiene dos citadores:
qIdent(name) → "name" // comillas dobles — el de DuckDB
qIdentFor(name,'spark') → `name` // backtick
El CTE de una tabla base usa qIdentFor ✅. Pero el de una VISTA expandida
(duck-plan.ts:345) y el de una relación inline de information_schema
(duck-plan.ts:266) usan qIdent — comillas dobles — también cuando el dialecto
es spark. Y runQuery pasa dialect:'spark' siempre (junction.ts:1588), porque
elegirMotorDeLectura devuelve spark o lanza.
Medido (scripts/bridge/c0b-comillas.ts):
⛔ WITH "v" AS (SELECT 1 AS a) SELECT * FROM v → ParseException [PARSE_SYNTAX_ERROR] at or near '"v"'
✅ WITH `v` AS (SELECT 1 AS a) SELECT * FROM v → [[1]]
⇒ Toda query del SQL Editor que toque una VISTA o information_schema muere con
ParseException. Es la 4ª repetición de la regla §12·18 del sustrato —cada cambio
de motor deja atrás piezas que asumían el anterior, y no las caza el compilador—:
B6 hizo el plan sensible al dialecto y dejó dos citadores atrás.
⚠️ D2 · Y la variante que no da error
SELECT "a" FROM (SELECT 1 AS a) t → [["a"]] ← la CADENA "a", no la columna
Sintaxis válida, sin error, resultado distinto. Es exactamente la clase que
dialect-divergence.ts llama semantica y
la razón de que ese fichero exista. Cualquier usuario que venga de Postgres o DuckDB
escribe "columna" por costumbre y recibe su propio nombre de columna como dato.
⇒ D1 y D2 no son hallazgos laterales: son la prueba de que el contrato hacía falta. Ninguno de los dos se ve sin declarar cómo se citan los identificadores.
3 · Las prácticas de la industria, y cómo aterrizan aquí
| Práctica | Quién | Cómo se aplica | |
|---|---|---|---|
| P1 | declarar el MODO, no el motor | Spark/Databricks documentan los dos interruptores por separado | §4·1 — los tres, pinneados |
| P2 | pinnear la versión del motor | Databricks (DBR) · Spark 4.0 volteó ansi.enabled | §4·1 — 4.1.2 es parte del contrato |
| P3 | behavior-change bundles con ventana | Snowflake: ≥4 semanas de prueba (opt-in) + ≥4 de opt-out; el bundle entero, nunca cambios sueltos | §4·4 — una subida de Spark es un cambio de dialecto |
| P4 | publicar el subconjunto garantizado | todos | §4·3 — y el repo ya lo hace en catálogo (GRAMATICA, desde:) |
| P5 | conformidad por corpus ejecutable | sqllogictest: DuckDB, Databend, Dolt, Calcite | §5 — se adopta el formato, no un framework nuevo |
| P6 | la divergencia se publica | — | §4·3 — dialect-divergence.ts sale a la superficie |
4 · El contrato — qué se declara, exactamente
4·1 · El pinneo
Carbon SQL v1 = Apache Spark SQL 4.1.2 con ansi.enabled=true,
storeAssignmentPolicy=ANSI, doubleQuotedIdentifiers=false, session.timeZone=GMT,
caseSensitive=false. Los valores se publican, y c0-pinneo.ts es su trinquete:
si el motor deriva del pinneo, el gate lo dice.
4·2 · ⭐ El citador — la decisión que D1/D2 obligan a tomar
| Identificador | backtick — `mi tabla` |
| Literal de cadena | comilla simple y comilla doble (heredado de Spark) |
| ⚠️ Consecuencia declarada | SELECT "col" devuelve la cadena col. Es la divergencia nº1 para quien viene de Postgres/DuckDB y va en la superficie publicada, en negrita |
⛔ Descartado subir doubleQuotedIdentifiers=true, aunque haría el dialecto más
amable: cambiaría el significado de todas las queries guardadas que hoy usan "…"
como literal. Es exactamente lo que P3 llama un cambio de comportamiento — y se hace por
ese conducto, con su ventana, o no se hace.
4·3 · El subconjunto garantizado, y lo excluido con su motivo
Lo que el motor acepta es un superconjunto de lo que prometemos. Se publica lo
prometido, con la forma que M3 ya usa (una tabla con desde:), y cada exclusión con
su motivo medido, nunca «pendiente de re-mirar»:
- Verbos de escritura hoy:
UPDATE·DELETE·MERGE·CREATE TABLE· CTAS. - Fuera:
INSERT … SELECT(⭐ ya no por imposibilidad: W4·4 demostró que la puerta sabe resolver una fuente) ·DROP(a propósito: el editor no tieneTABLE_DROP). - Las divergencias publicadas salen de
dialect-divergence.ts, con su clase (dialecto/capacidad/semantica) — y lassemanticaprimero, porque son las que no dan error.
4·4 · Versionado y cambio de comportamiento
Copiado de Snowflake porque resuelve exactamente nuestro problema: subir Spark es cambiar el dialecto, y hoy se desplegaría en silencio.
bundle 2026_08 · se anuncia con el diff del contrato
· ventana de PRUEBA — desactivado, opt-in
· ventana de OPT-OUT — activado, se puede desactivar
· el bundle ENTERO; nunca un cambio suelto
⚠️ Requisito previo que hoy no existe: poder correr dos versiones de Spark a la
vez para que la ventana signifique algo. Con CPUS_ALL_REGIONS=12 no caben. ⇒ La
política se declara ahora (que es lo que evita el cambio silencioso) y la ventana
se implementa cuando haya cuota. Se dice, en vez de prometer un mecanismo sin sustrato.
5 · El corpus — forma sqllogictest, y la asimetría que lo ordena
5·1 · Por qué ese formato y no uno propio
Es el de facto (SQLite, DuckDB, Databend, Dolt, un port en Calcite), es texto plano
autocontenido, y separa prototipo (sin resultados) de script completo (con ellos).
Adoptamos el formato, no un framework: el ejecutor es el arnés que ya existe
(b5-dialecto.ts + equivalence.ts).
5·2 · ⭐⭐ Y aquí está la trampa, que es lo que hace este corpus distinto
sqllogictest tiene un modo completion: se corre el prototipo contra un motor de
referencia y se rellenan los resultados esperados.
⛔ Para el corpus de ESCRITURA, completion es una trampa — y hay que decirlo alto
porque es el atajo que se va a apetecer. Sólo tenemos un motor de escritura.
Generar los esperados desde el motor bajo prueba produce un corpus que siempre pasa
y no mide nada: convierte «lo que Spark hace» en «lo que es correcto».⇒ Los esperados de escritura se AUTORIZAN a mano, desde el estándar y desde el
pinneo de §1. Es más caro y es el único modo de que el corpus pueda decir que no.(Para el corpus de LECTURA sí vale completion: DuckDB sigue desplegado y es una
referencia legítima e independiente.)
5·3 · Las cuatro clases de fallo — y la nueva
b5-dialecto.ts separa tres. La escritura obliga a una cuarta, que es la razón de ser
de todo esto:
| Clase | Qué pasa | Coste |
|---|---|---|
RUIDOSO | no corre | barato: se ve enseguida |
SILENCIOSO | corre y devuelve otra cosa | caro |
INCOMPARABLE | el comparador no decide | no es un aprobado |
⛔ CORRUPTOR | corre, el ledger dice committed, y el dato queda MAL | el único irreversible |
⇒ CORRUPTOR es la única clase que justifica la prioridad P0·1. Una divergencia de
lectura da un número raro que alguien puede ver; ésta deja el dato mal y nadie mira.
5·4 · El doble oráculo — porque ya lo tenemos
Una sonda de escritura no se comprueba mirando si lanzó. Se comprueba dos veces, y la segunda mitad ya está construida (W3·c):
① ORÁCULO DE VALOR leer la tabla después → ¿el dato es el esperado?
② ORÁCULO DE GOBIERNO el ledger → ¿landed=true · attribution=exclusive?
⭐ Una sonda que pasa ① y falla ② no es un aprobado: significa que el dato es correcto y la escritura no es reconciliable, que es un defecto distinto y también grave. Ningún corpus de dialecto de la industria mide esto; nosotros podemos porque la escritura va firmada.
5·5 · Los ejes — y el verbo no es uno
Es la lección de M6, medida: «UPDATE lo bloquea la TABLA e INSERT la FORMA». Un
corpus organizado por verbo mide el eje equivocado.
| Eje | Valores |
|---|---|
Asignación (⭐ storeAssignmentPolicy=ANSI) | compatible · estrechante · incompatible → ¿lanza, trunca o redondea? |
| Tipo destino | los 12 tipos del vocabulario Databricks (types.ts) |
| Predicado | NULL · cast en el WHERE · función · subconsulta |
| Forma | con prólogo WITH · destino cualificado vs pelado · CTE homónimo del destino |
| Temporal | timestamps con session.timeZone=GMT · fechas con timeParserPolicy=CORRECTED |
| Verbo | UPDATE · DELETE · MERGE · CREATE TABLE · CTAS |
5·6 · ⚠️ El problema físico: una sonda de escritura muta, y el editor no puede DROP
lakekeeper-spark-editor no tiene TABLE_DROP, a propósito. Los gates de W4 ya
dejan una tabla huérfana por corrida, y hay 67 huérfanas censadas. Un corpus de
escritura con decenas de sondas fabricaría deriva a escala.
⭐ La salida la da el formato, no un privilegio nuevo: en Iceberg una escritura es
un snapshot, y se revierte con ALTER TABLE … ROLLBACK TO SNAPSHOT. ⇒ el corpus:
- crea N tablas fijas y reutilizables en un namespace propio (
main.corpus), una sola vez; - antes de cada sonda anota el snapshot actual;
- ejecuta, comprueba los dos oráculos;
- revierte al snapshot anotado — la tabla vuelve a su estado y no hace falta soltar nada.
5·7 · 🏁 El gate previo, CORRIDO — y lo que obliga a meter en el arnés
c3-0-gate-rollback.ts, salida 0 (2026-08-11). Las cuatro condiciones, con su
negativo:
la sonda MUTA de verdad ......... SÍ ✅ [3,6] → [3,105]
la reversión se ACEPTA .......... SÍ ✅
el DATO vuelve a la línea base .. SÍ ✅ [3,105] → [3,6]
el DROP sigue NEGADO ............ SÍ ✅ Forbidden: … no puede dropTable — falta TABLE_DROP
⇒ «Revierte sin poder soltar» está probado por las dos mitades, y el negativo lo emite nuestra propia cara, con su motivo.
La sintaxis, medida (no estaba decidida):
✅ CALL carbon.system.rollback_to_snapshot(table => '…', snapshot_id => <id>L) | es la buena — procedimiento del catálogo |
⛔ ALTER TABLE … ROLLBACK TO SNAPSHOT | ParseException en Spark: es sintaxis de Trino/Delta |
⚠️⚠️ Tres trampas del INSTRUMENTO que el arnés de C3 tiene que llevar de serie
Las tres salieron corriendo este gate, y las tres producían un rojo con el sistema sano — que es la forma más cara de equivocarse:
- ⭐⭐ Los
snapshot_idson de 64 bits yJSON.parselos DESTRUYE (>2^53). El gate pidió revertir a2797089430430489600—la cola de ceros es la firma del redondeo— y recibióCannot roll back to unknown snapshot id, que no es un error de privilegio y por poco hace concluir que el principal no podía revertir. ⇒ el id se pide al motor comoCAST(… AS STRING)y no toca nunca unnumber. ⚠️ Esta trampa ya estaba documentada en el repo —fue una de las dos causas del camino de escritura roto— y volvió a morder igual. - ⭐ El snapshot ACTUAL no es el más reciente. Revertir mueve el puntero de la
rama; no borra snapshots, así que
max(committed_at)sobre.snapshotsseguía enseñando S1 después de una reversión correcta. ⇒ se lee de.refswherename='main'. - El puente topa
wait_timeout_sen 50 (422 si te pasas), y el 422 salía por pantalla comoundefinedporque el arnés no miraba el estado HTTP antes del cuerpo.
⇒ Las tres van al arnés de C3 como utilidades compartidas, no como cuidado que cada sonda deba recordar. Una sonda de escritura que lea mal un snapshot no da un fallo: da un veredicto falso, que es justo lo que este corpus existe para no producir.
Y queda una tabla de corpus creada: main.default.carbon_corpus_rollback_probe,
dada de alta en Index por la cara (no es una huérfana) y reutilizable entre corridas.
5·8 · 🏁 C3, corrido — y encontró un CORRUPTOR real a la primera
lib/compute/write-probes.ts (el corpus, como datos) + scripts/bridge/c3-corpus-escritura.ts
(el arnés). 16 sondas · 6 ejes · salida 0.
✅ SOPORTADA 15 🟡 RUIDOSO 0 ⚪ INCOMPARABLE 0 ⛔ CORRUPTOR 1 (🔒 1 en línea base)
⛔⛔ El hallazgo: DELETE trata UNKNOWN como TRUE y borra la fila
Caracterizado con c3-diag-nulos.ts sobre (1,'a') (2,'b') (3,NULL):
| Sentencia | Resultado | |
|---|---|---|
SELECT … WHERE s <> 'a' | sólo la fila 2 | ✅ |
SELECT id, (s <> 'a') | [1,false] [2,true] [3,null] | ✅ el predicado se evalúa BIEN |
UPDATE … WHERE s <> 'a' | toca sólo la fila 2 | ✅ |
DELETE … WHERE s = 'b' | borra sólo la 2 | ✅ |
⛔ DELETE … WHERE s <> 'a' | borra la 2 Y la 3 | INCORRECTO |
DELETE … AND s IS NOT NULL | borra sólo la 2 | ✅ el rodeo |
⇒ No es el evaluador de expresiones: es el DELETE. Compatible con un borrado
implementado como «conservar donde NOT(pred)», que sobre UNKNOWN no conserva.
Es el CORRUPTOR de manual: no da error, commitea, y destruye una fila que el
usuario no pidió borrar. Va a la superficie publicada (C4) como divergencia
semantica, que son las que no avisan.
⭐ Tres esperados que estaban MAL, y el corpus los cazó a la primera
Es la prueba de que confianza no era ceremonia:
| Sonda | Yo autoricé | La medición |
|---|---|---|
forma-cte-antes-de-update | «debe lanzar» | ⛔ mío: generalicé el ParseException de WITH … CREATE TABLE. Spark sí admite WITH … UPDATE y lo aplica bien |
arit-division-cero-en-update | marca DIVIDE_BY_ZERO | INT/0 da Infinity, no error; lo para la ASIGNACIÓN (CAST_OVERFLOW_IN_TABLE_INSERT), no la aritmética |
arit-infinity-a-double | «el Infinity se persistirá» | falsa en buen sentido: lanza DIVIDE_BY_ZERO |
⭐ Y la asimetría que dejan las dos últimas conviene no olvidarla: la misma
expresión la para la aritmética con destino DOUBLE y la asignación con destino INT.
Las dos paran; no se puede confiar en cuál, y por eso el eje de asignación es un eje
y no un detalle.
La línea base, y por qué el gate va verde con un corruptor dentro
DIVERGENCIAS_CONOCIDAS separa «defecto medido y aceptado hoy» de «defecto NUEVO».
Sin esa raya el gate sería un informe permanentemente rojo, que nadie lee a la semana.
Cualquier corruptor que no esté en la lista lo pone rojo — eso es lo que lo hace un
trinquete. Y el arnés avisa también al revés: una entrada de la línea base que deja de
dispararse se reporta, porque o se arregló (y hay que sacarla) o la sonda dejó de medir.
⚠️ Lo que C3 NO cubrió, dicho para que no se lea como cobertura
- El oráculo de GOBIERNO (② de §5·4) no se ejerce aquí. El ledger sólo lo escribe la
puerta, y este corpus mide el dialecto del motor, que vive por debajo. Correrlo por
sonda mediría el ledger, no el dialecto, y multiplicaría el coste. La atribución ya la
cubre
w3-c-gate-atribucion.ts. Se dice en vez de dar el doble oráculo por hecho. CREATE TABLE/CTAS no tienen sonda todavía — el eje de verbo está a medias.- Concurrencia, cero. Dos escrituras a la vez sobre la misma tabla no se prueban.
5·9 · 🏁 C2, corrido — 114 sondas, 18 ejes, y un defecto del transporte
lib/compute/read-probes.ts + scripts/bridge/c2-corpus-lectura.ts. Salida 0.
✅ 113/114 SOPORTADA 🟡 0 RUIDOSO ⛔ 1 SILENCIOSO (🔒 1 conocida) ⚪ 0 INCOMPARABLE
Ejes cubiertos: tipos · cast · aritmética · comparación (3VL) · texto · temporal · agregación · agrupación · ventana · join · conjuntos · subconsulta · CTE · orden · complejos · JSON · condicional · lambda.
⚠️ No sustituye a SQL_FEATURE_PROBES (25) y no se fusionan: aquéllas DERIVAN
features[] para el capability-check; éstas contestan «¿lo que Carbon SQL promete es
lo que el motor hace?». Mismo aspecto, trabajos distintos.
⛔⛔ El hallazgo: un BIGINT pierde precisión al llegar al cliente
SELECT CAST(9223372036854775807 AS BIGINT) → 9223372036854776000 ⛔
SELECT CAST(… AS STRING) → "9223372036854775807" ✅
La segunda sonda diagnostica la primera: el valor es correcto en Spark y se
pierde al serializar — el number de JS no llega a 2^63 y JSON.parse redondea.
⇒ No es divergencia de dialecto: es defecto del TRANSPORTE. Y llega igual al
navegador, así que el usuario ve un número equivocado sin ningún error. Un id de
64 bits, un importe en céntimos o una marca de tiempo en micros se corrompen en
silencio.
⭐⭐ Es la TERCERA vez que esta misma causa muerde: los snapshot-id del write path (memoria: «JSON.parse destruyendo los snapshot-id de 64 bits»), el gate de C3·0, y ahora dato de usuario. Ya no es una anécdota de un arnés: es una propiedad del transporte que hay que arreglar donde se origina.
⭐ Y el contraste lo deja medido el par de sondas: un DECIMAL sí viaja como
texto —[["1.25"]], y está bien, porque preserva la precisión—. El transporte ya sabe
hacerlo bien; simplemente no lo hace con los enteros de 64 bits. Arreglo: que el
puente serialice BIGINT/LONG como texto, igual que DECIMAL.
⭐ Cuatro esperados míos, otra vez
Como en C3, confianza se ganó el sitio: de los 5 desacuerdos iniciales, 4 eran míos
—el DECIMAL como texto, la escala del cociente, size(NULL)=null (el -1 es folclore de
legacy.sizeOfNull, apagado en Spark 4) y un ROLLUP sin ORDER BY, que falló por
el ORDEN y no por el valor. Esa última merece regla: una sonda que asume orden sin
ORDER BY mide el plan, no la semántica.
5·10 · 🏁 C4 · la superficie se GENERA, y en frío
⭐ La decisión de forma que lo ordena todo: el contrato pasa a ser DATO
(lib/warehouse/carbon-sql-contract.ts), no prosa. Es el mismo movimiento que M3 hizo
con la gramática de catálogo, y compra lo mismo: la superficie se genera (no
envejece), cada punto declara desde qué versión rige, y —lo que resultó ser la pieza
clave— el motor se puede contrastar con la declaración en vez de con el recuerdo de
alguien.
scripts/warehouse/carbon-sql-surface.ts → docs/warehouse-sql/carbon-sql-contract-v1.md
(201 líneas). Sale de cuatro fuentes: el contrato declarado, los dos corpus y
dialect-divergence.ts. Corre en frío —sin red, sin BD, sin motor—, que es lo que
permite que el trinquete viva en CI.
Lo que publica, en este orden a propósito: el pinneo (con las 🔴 semánticas marcadas) ·
el citado, con la advertencia de "col" en negrita · los verbos, cada exclusión con
su motivo medido · los límites con su sitio en el código · §5 las divergencias
que NO dan error, primero de todo · las de dialecto · y cómo se verifica.
⭐ Y publica lo que suele esconderse: el número de esperados todavía provisional,
que es «cuánto de este contrato está verificado frente a cuánto está supuesto», y una
sección explícita de lo que los corpus NO cubren (concurrencia, volumen, el oráculo
de gobierno).
⚠️ Sin fecha de generación, a propósito: un timestamp haría que el doc cambiara en cada corrida y el trinquete no podría distinguir «cambió el contrato» de «se regeneró». La versión la da el bundle.
5·11 · 🏁 C5 · la política, con dos dientes — y los dos probados en rojo
La ventana de opt-out de Snowflake no cabe todavía (§4·4: exige dos versiones del motor a la vez, y la cuota no da). Pero lo que la política tiene que impedir de verdad es el cambio silencioso, y eso sí se puede hoy:
| Diente | Qué impide | Probado |
|---|---|---|
⭐ c0-pinneo.ts como detector de deriva | que el MOTOR se aleje del contrato sin que nadie se entere | salida 0 hoy: 9 interruptores casan + 5 sondas de comportamiento |
| ⭐ el guarda del bundle | que el CONTRATO cambie un valor 🔴 sin subir el bundle | forzado: cambiar timeZone con el bundle quieto → exit 1 con su motivo |
⭐ Y la pieza no hubo que inventarla: una deriva entre lo declarado y lo medido es, por definición, un cambio de comportamiento. No hacía falta un mecanismo nuevo —hacía falta que el contrato existiera—. C0 pasó de censo a gate sin código nuevo de fondo.
⚠️ El mensaje de C0 dice explícitamente lo que no hay que hacer: «esto no se arregla editando el contrato para que case». Sin esa frase, el arreglo natural de un gate rojo es falsear la declaración, que es justo el fallo que evita.
6 · Las fases, con su gate
| Qué | Gate | Estado | |
|---|---|---|---|
| C0 | censo del pinneo | c0-pinneo.ts publica los 10 valores + 5 sondas de comportamiento | 🏁 HECHO |
| C1 | ⛔ arreglar D1 · declarar D2 | c1-gate-citado.ts salida 0 — el plan real contra el parser real, con su negativo | 🏁 HECHO (2026-08-11) — ver §6·bis |
| C2 | corpus de LECTURA, todo el espectro | c2-corpus-lectura.ts salida 0 · 114 sondas · 18 ejes · 113 SOPORTADA | 🏁 HECHO — ver §5·9 |
| C3·0 | ⭐ gate previo — ¿se puede deshacer por snapshot? | c3-0-gate-rollback.ts salida 0, con el DROP negado como negativo | 🏁 HECHO — ver §5·7 |
| C3 | ⭐⭐ corpus de ESCRITURA | c3-corpus-escritura.ts salida 0 · 16 sondas · 15 SOPORTADA · 0 corruptores nuevos | 🏁 HECHO — ver §5·8 |
| C4 | la superficie publicada, generada | npm run check:carbon-sql-contract verde · el trinquete probado en rojo | 🏁 HECHO — ver §5·10 |
| C5 | política de cambio de comportamiento | c0-pinneo.ts salida 0 (deriva) + el guarda del bundle, probado en rojo | 🏁 HECHO — ver §5·11 |
⭐ C1 va primero y no es negociable: medir un dialecto sobre un plan que emite sintaxis inválida mediría el defecto, no el dialecto.
6·bis · 🏁 C1, cerrada — y el alcance real fue el DOBLE
El defecto se anunció como «dos qIdent». Al abrirlo, el citado de identificadores se
decidía en cuatro sitios, y sólo uno sabía del dialecto:
| Dónde | Qué citaba | Estaba |
|---|---|---|
duck-plan.ts qIdentFor | CTE de tabla base | ✅ bien |
duck-plan.ts qIdent | CTE de VISTA | ⛔ comilla doble |
duck-plan.ts qIdent | CTE de relación inline | ⛔ comilla doble |
inline-relation.ts ident | alias de columna del VALUES | ⛔ comilla doble |
sql-parse.ts (inline) | la referencia information_schema.<rel> | ⛔ comilla doble |
⭐ Y la referencia y la CTE tienen que citar IGUAL, o el nombre no casa con su relación: arreglar sólo uno de los dos habría cambiado ParseException por «tabla no encontrada».
Lo entregado: nace lib/warehouse/query/ident.ts
— un citador, sensible al dialecto — y los cinco sitios pasan por él. Es la versión
mínima del cimiento que P1·1 propone, y su primer cliente.
Dos cosas que salieron por el camino y no estaban en el plan
scanTableRefsno sabía leer backticks. Si emitimos backticks y el escáner sólo entiende comillas dobles,FROM `mi tabla`deja de verse como posición de tabla. Fallaba cerrado (la puerta rechaza lo que no resuelve), así que no era un agujero — pero daba el mensaje equivocado. Añadido, en el escáner y en el recorrido de nombres cualificados.extractCteNamesno reconocía CTEs citadas. Carbon SQL declara el backtick como su identificador, así queWITH `x` AS (…)es la forma que el usuario tiene delante — y su CTE se tomaba por una tabla del Warehouse.
⚠️ D2 no se arregla: se declara
SELECT "a" sigue devolviendo la cadena a, porque es el comportamiento de Spark con
el pinneo de §1 y §4·2 decidió no tocarlo. Lo que sí cambió es que la superficie
deje de invitar al error: Monaco estaba en pgsql —que pinta "col" como
identificador— y pasa a sql. ⚠️ Ningún lenguaje de serie de Monaco modela la comilla
doble de Spark; la definición Monarch propia va con C4, y se anota en vez de darlo
por cerrado.
El gate, y por qué su negativo no está simulado
c1-gate-citado.ts construye un plan real con buildDuckReadPlan y manda su salida
a Spark — porque el test unitario fija la forma y sólo el motor dice si parsea, y
D1 vivió semanas pasando sus tests. El negativo se produce con el mismo código
pidiendo dialect:'duckdb', que es literalmente lo que se emitía antes:
spark · tabla base ✅ · VISTA ✅ · information_schema ✅
duckdb · VISTA ⛔ ParseException '"c1_vista"' · information_schema ⛔ '"information_schema.tables"'
🏁 salida 0
⚠️ Y el gate falló antes de acertar, por tercera vez en la jornada: exigía que los
tres negativos fallaran. La tabla base en dialecto duckdb no emite ningún CTE
—el CTE de alias es de la rama de Spark—, así que su SQL no lleva comillas y pedirle a
Spark que lo rechace era pedir lo imposible. Es §12·19 otra vez, y esta vez contra mí:
un gate que mide lo que le viene bien no mide el sistema. El negativo se evalúa ahora
sólo donde había defecto, y el caso lo declara (esD1).
Regresión fijada: 6 pruebas nuevas en inline-relation.test.ts, en las dos
direcciones (Spark lleva backtick y DuckDB sigue llevando comilla doble) — un test
que sólo mirara Spark aprobaría un citador que hubiera roto el otro motor.
Gates: vitest 326/326 en los módulos tocados · tsc sin errores nuevos
(los 4 ficheros con error son preexistentes, verificado contra HEAD) · suite completa
sin fallos nuevos (los 16 rojos son los mismos en HEAD).
⚠️ Y un arreglo de una línea que entra con C1 porque miente en la misma frontera:
Monaco está configurado en pgsql (mapa-computo.md §1). El
editor resalta un dialecto que no es el que ejecuta, y es justo el que hace creer
que "col" es una columna.
7 · Lo que este documento NO decide
| El tokenizador | es P1·1 y va después. ⭐ Pero C1 le da su primer cliente: los dos citadores de duck-plan.ts son «un parser malo en diez sitios» en miniatura |
INSERT … SELECT | entra o no en v1 según lo que mida C3, no por decisión previa |
Subir doubleQuotedIdentifiers | §4·2 — sólo por el conducto de P3 |
| Qué se hace con las 67 huérfanas | decisión del owner. C3 no las agrava (revierte por snapshot) pero tampoco las limpia |
| El segundo motor | DuckDB/Polars o streaming, y depende de C3: dos motores sin corpus de escritura sería reabrir el agujero a sabiendas |
8 · 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/c0-pinneo.ts # el pinneo: qué ES Carbon SQL hoy
npx tsx scripts/bridge/c0b-comillas.ts # D1: ¿parsea lo que el plan emite?
npx tsx scripts/bridge/b9-use-namespace.ts # el namespace de sesión (B9)