Published

Carbon SQL · el CONTRATO — declararlo y medirlo, que son la misma pieza

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

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 de mapa-computo.md §7).

Manda sustrato-04.md sobre 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 medirdeja un contrato sin oráculo: nadie puede decir si se cumple
Medir sin declarardeja 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
ClaveValorPor qué está en el contrato
spark.sql.ansi.enabledtrueerrores en runtime en vez de null
spark.sql.storeAssignmentPolicyANSIcasting implícito AL INSERTAR — gobierna el camino de ESCRITURA, y es independiente del anterior
spark.sql.ansi.doubleQuotedIdentifiersfalse"x" es un LITERAL DE CADENA, no un identificador
spark.sql.ansi.enforceReservedKeywordsfalselas palabras reservadas ANSI no se imponen
spark.sql.session.timeZoneGMTsemántica de timestamps
spark.sql.caseSensitivefalseresolución de identificadores
spark.sql.ansi.relationPrecedencefalseprecedencia de JOIN frente a la coma
spark.sql.legacy.timeParserPolicyCORRECTEDparseo de fechas
spark.sql.sources.partitionOverwriteModeSTATICsemántica de sobreescritura
spark.sql.defaultCatalogcarbona 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ácticaQuiénCómo se aplica
P1declarar el MODO, no el motorSpark/Databricks documentan los dos interruptores por separado§4·1 — los tres, pinneados
P2pinnear la versión del motorDatabricks (DBR) · Spark 4.0 volteó ansi.enabled§4·1 — 4.1.2 es parte del contrato
P3behavior-change bundles con ventanaSnowflake: ≥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
P4publicar el subconjunto garantizadotodos§4·3 — y el repo ya lo hace en catálogo (GRAMATICA, desde:)
P5conformidad por corpus ejecutablesqllogictest: DuckDB, Databend, Dolt, Calcite§5 — se adopta el formato, no un framework nuevo
P6la 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

Identificadorbacktick`mi tabla`
Literal de cadenacomilla simple y comilla doble (heredado de Spark)
⚠️ Consecuencia declaradaSELECT "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 tiene TABLE_DROP).
  • Las divergencias publicadas salen de dialect-divergence.ts, con su clase (dialecto / capacidad / semantica) — y las semantica primero, 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:

ClaseQué pasaCoste
RUIDOSOno correbarato: se ve enseguida
SILENCIOSOcorre y devuelve otra cosacaro
INCOMPARABLEel comparador no decideno es un aprobado
CORRUPTORcorre, el ledger dice committed, y el dato queda MALel ú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.

EjeValores
Asignación (⭐ storeAssignmentPolicy=ANSI)compatible · estrechante · incompatible → ¿lanza, trunca o redondea?
Tipo destinolos 12 tipos del vocabulario Databricks (types.ts)
PredicadoNULL · cast en el WHERE · función · subconsulta
Formacon prólogo WITH · destino cualificado vs pelado · CTE homónimo del destino
Temporaltimestamps con session.timeZone=GMT · fechas con timeParserPolicy=CORRECTED
VerboUPDATE · 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:

  1. crea N tablas fijas y reutilizables en un namespace propio (main.corpus), una sola vez;
  2. antes de cada sonda anota el snapshot actual;
  3. ejecuta, comprueba los dos oráculos;
  4. 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 SNAPSHOTParseException 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:

  1. ⭐⭐ Los snapshot_id son de 64 bits y JSON.parse los DESTRUYE (>2^53). El gate pidió revertir a 2797089430430489600 —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 como CAST(… AS STRING) y no toca nunca un number. ⚠️ Esta trampa ya estaba documentada en el repo —fue una de las dos causas del camino de escritura roto— y volvió a morder igual.
  2. 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 .snapshots seguía enseñando S1 después de una reversión correcta. ⇒ se lee de .refs where name='main'.
  3. El puente topa wait_timeout_s en 50 (422 si te pasas), y el 422 salía por pantalla como undefined porque 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):

SentenciaResultado
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 3INCORRECTO
DELETE … AND s IS NOT NULLborra 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:

SondaYo autoricéLa medición
forma-cte-antes-de-update«debe lanzar»mío: generalicé el ParseException de WITH … CREATE TABLE. Spark admite WITH … UPDATE y lo aplica bien
arit-division-cero-en-updatemarca DIVIDE_BY_ZEROINT/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 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.tsdocs/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:

DienteQué impideProbado
c0-pinneo.ts como detector de derivaque el MOTOR se aleje del contrato sin que nadie se enteresalida 0 hoy: 9 interruptores casan + 5 sondas de comportamiento
el guarda del bundleque el CONTRATO cambie un valor 🔴 sin subir el bundleforzado: 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éGateEstado
C0censo del pinneoc0-pinneo.ts publica los 10 valores + 5 sondas de comportamiento🏁 HECHO
C1arreglar D1 · declarar D2c1-gate-citado.ts salida 0 — el plan real contra el parser real, con su negativo🏁 HECHO (2026-08-11) — ver §6·bis
C2corpus de LECTURA, todo el espectroc2-corpus-lectura.ts salida 0 · 114 sondas · 18 ejes · 113 SOPORTADA🏁 HECHO — ver §5·9
C3·0gate 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 ESCRITURAc3-corpus-escritura.ts salida 0 · 16 sondas · 15 SOPORTADA · 0 corruptores nuevos🏁 HECHO — ver §5·8
C4la superficie publicada, generadanpm run check:carbon-sql-contract verde · el trinquete probado en rojo🏁 HECHO — ver §5·10
C5política de cambio de comportamientoc0-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óndeQué citabaEstaba
duck-plan.ts qIdentForCTE de tabla base✅ bien
duck-plan.ts qIdentCTE de VISTA⛔ comilla doble
duck-plan.ts qIdentCTE de relación inline⛔ comilla doble
inline-relation.ts identalias 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.tsun 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

  1. scanTableRefs no 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.
  2. extractCteNames no reconocía CTEs citadas. Carbon SQL declara el backtick como su identificador, así que WITH `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 tokenizadores 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 … SELECTentra 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érfanasdecisión del owner. C3 no las agrava (revierte por snapshot) pero tampoco las limpia
El segundo motorDuckDB/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)