Carbon SQL v1 — el contrato
⚠️ ESTE DOCUMENTO SE GENERA. No lo edites: los cambios se pierden en la
siguiente corrida. La fuente eslib/warehouse/carbon-sql-contract.tsy los dos
corpus. Para regenerarlo:npx tsx scripts/warehouse/carbon-sql-surface.ts.Bundle de comportamiento vigente:
2026_08.
Carbon SQL es un dialecto HEREDADO, no inventado. Es Spark SQL con ANSI, y lo que este documento hace es declararlo con precisión —motor, versión, interruptores— y publicar dónde no se comporta como cabría esperar. Un dialecto que sólo dice «Spark SQL» no es un contrato: es un puntero a lo que el motor haga hoy.
1 · El pinneo
Apache Spark SQL 4.1.2, con estos valores. Los marcados 🔴 son semánticos: si cambian, la misma sentencia puede significar otra cosa.
| Clave | Valor | Gobierna | |
|---|---|---|---|
| 🔴 | spark.sql.ansi.enabled | true | errores en tiempo de ejecución en vez de null silencioso |
| 🔴 | spark.sql.storeAssignmentPolicy | ANSI | ⭐ el casting implícito AL INSERTAR EN UNA TABLA. Es INDEPENDIENTE del anterior y es el que gobierna el camino de escritura |
| 🔴 | spark.sql.ansi.doubleQuotedIdentifiers | false | ⭐ que "x" sea un LITERAL DE CADENA y no un identificador |
| ⚪ | spark.sql.ansi.enforceReservedKeywords | false | que las palabras reservadas ANSI no se impongan |
| 🔴 | spark.sql.session.timeZone | GMT | la semántica de todo timestamp |
| 🔴 | spark.sql.caseSensitive | false | la resolución de identificadores |
| 🔴 | spark.sql.legacy.timeParserPolicy | CORRECTED | que una fecha inválida lance en vez de deslizarse |
| ⚪ | spark.sql.ansi.relationPrecedence | false | la precedencia de JOIN frente a la coma |
| 🔴 | spark.sql.sources.partitionOverwriteMode | STATIC | la semántica de sobreescritura de particiones |
⇒ Verificado contra el motor vivo por scripts/bridge/c0-pinneo.ts. Una deriva
entre esta tabla y el motor es un cambio de comportamiento no anunciado (ver §7).
2 · Cómo se citan las cosas
| Identificador | backtick |
| Literal de cadena | comilla simple '…' y comilla doble "…" |
⚠️ SELECT "col" devuelve la CADENA col, no la columna. Es la divergencia número uno para quien viene de PostgreSQL o DuckDB, y no da ningún error. Se hereda de Spark con el pinneo vigente y NO se cambia sin bundle: activar doubleQuotedIdentifiers alteraría el significado de todas las queries ya guardadas.
3 · Los verbos
Dentro del contrato
| Verbo | Desde | |
|---|---|---|
SELECT / WITH | v1 | |
UPDATE | v1 | |
DELETE | v1 | ⚠️ ver la divergencia delete-igualdad-negada |
MERGE INTO | v1 | |
CREATE TABLE | v1 | el destino se materializa en Index por la cara |
CREATE TABLE … AS SELECT | v1 |
Fuera — cada uno con su motivo
| Qué | Por qué no está |
|---|---|
INSERT … SELECT | Fuera de los verbos de la puerta. ⭐ Ya NO es imposibilidad: W4·4 demostró que la puerta sabe resolver una fuente. Es decisión pendiente, y depende de que el corpus de escritura la cubra. |
DROP TABLE | A PROPÓSITO: el principal del editor no tiene TABLE_DROP. Crear de más deja una tabla que sobra —contable y borrable—; soltar de menos no deja nada. |
QUALIFY | Spark no lo tiene (DuckDB sí). Se escribe con una subconsulta sobre row_number(). Divergencia de CAPACIDAD, medida. |
Vistas materializadas | No existen en la plataforma todavía. |
Time travel en SQL | Los snapshots existen; el SQL no los expone aún. |
Renombrar un NAMESPACE (esquema) | ⛔ Límite del FORMATO, no decisión nuestra. El spec de Iceberg REST tiene renameTable y renameView pero no renameNamespace (apache/iceberg #13023, abierto). Ni siquiera la referencia lo tiene entero: Unity Catalog renombra un ESQUEMA pero no un CATÁLOGO por SQL — hay que crear otro y mover los activos. ⇒ El camino equivalente aquí es el mismo, y es barato: renameTable en lote no mueve bytes. Renombrar una TABLA sí está soportado. |
3·bis · El namespace por defecto — qué es y qué no es
No te obliga a vivir en un solo catálogo. Es una cadena de fallback para
los nombres SIN cualificar, no una restricción: la coordenada completa
(catalogo.esquema.tabla) funciona siempre, apunte a donde apunte el default.
Es como funciona la referencia: Databricks resuelve USE CATALOG (sesión) → config
del cluster → default del workspace; Snowflake lo lleva como propiedad del
usuario (ALTER USER … SET DEFAULT_NAMESPACE = db.schema).
⚠️ Y son DOS defaults distintos, que conviene no confundir:
| Qué decide | Cuándo | |
|---|---|---|
| colocación | dónde nace un dataset al que nadie dio coordenada | al escribir |
| resolución | qué significa un nombre pelado | al leer |
Resolución hoy: main.default — ⚠️ declarado TRANSITORIO: es un valor global y debe pasar a ser por workspace, como el workspace default catalog de Databricks. Vale mientras haya un solo inquilino con datos; decirlo evita que se fosilice como constante.
4 · Los límites, y dónde viven en el código
| Qué | Valor | Dónde |
|---|---|---|
| Objetos del Warehouse leídos por principal | 5.000 | catalog-sql/grammar.ts · TOPE_OBJETOS |
Filas de una relación inline (information_schema) | 20.000 | catalog-sql/inline-relation.ts · TOPE_FILAS_INLINE |
| Espera máxima de una sentencia | 50 s | services/spark-bridge · wait_timeout_s“ |
| Anidamiento de vistas | 12 | warehouse/query/duck-plan.ts · MAX_VIEW_DEPTH |
| Tamaño del resultado | INLINE — sin EXTERNAL_LINKS | pendiente (B3) |
5 · ⛔ Divergencias que NO dan error
Van primero a propósito: son las que devuelven un resultado distinto sin que nada falle, y por tanto las únicas que un usuario no puede descubrir por su cuenta.
nulo-distinto-preserva · plano de ESCRITURA
⛔ 2026-08-11 · DELETE trata UNKNOWN como TRUE y BORRA la fila. Caracterizado con c3-diag-nulos.ts: el predicado se evalúa bien (NULL <> 'a' → null), el SELECT con ese predicado devuelve sólo la fila que casa, y el UPDATE con el MISMO predicado toca sólo esa fila — pero el DELETE se lleva también la del NULL. Compatible con un borrado implementado como «conservar donde NOT(pred)», que sobre UNKNOWN no conserva. ⇒ CORRUPTOR real: no da error, commitea y destruye una fila que el usuario no pidió borrar. Rodeo del usuario: añadir AND <col> IS NOT NULL. Tiene que salir en la superficie publicada (C4) como divergencia de clase semantica, que son las que no avisan.
tipo-bigint · plano de LECTURA
⛔⛔ 2026-08-11 · UN BIGINT PIERDE PRECISIÓN AL LLEGAR AL CLIENTE. 9223372036854775807 → 9223372036854776000. La sonda hermana tipo-bigint-como-texto demuestra que el valor es correcto en Spark: se pierde al serializar, porque 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 ⇒ el usuario ve un número equivocado sin ningún error. Es la 3ª vez que esta causa muerde (los snapshot-id del write path, el gate de C3·0 y ahora dato de usuario). Arreglo: que el puente serialice los enteros de 64 bits como texto —que es lo que YA hace con DECIMAL— y que la superficie los trate como tales. Se queda en la línea base hasta entonces.
6 · Divergencias de dialecto y de capacidad
Éstas sí se delatan solas (la query no corre), salvo las marcadas semantica.
Se publican para que nadie las descubra en producción.
| Función | Clase | Se escribía así | En Carbon SQL | Qué se hace |
|---|---|---|---|---|
null-ordering | ⛔ semantica | ORDER BY x → [1, 2, null] (NULLS LAST por defecto) | ORDER BY x NULLS LAST → [1, 2, null] (explícito; su defecto es FIRST) | rechazar |
timestamp-arithmetic | ⛔ semantica | DATE '2026-01-02' - DATE '2026-01-01' → 1 | datediff(DATE '2026-01-02', DATE '2026-01-01') → 1 | rechazar |
array-functions | dialecto | len([1, 2, 3]) | size(array(1, 2, 3)) | traducir |
struct-access | dialecto | {'a': 1}.a | named_struct('a', 1).a | traducir |
regex | dialecto | regexp_replace('a1b2', '[0-9]', 'X', 'g') | regexp_replace('a1b2', '[0-9]', 'X') | traducir |
json-access | dialecto | json_extract_string('{"a":"v"}', '$.a') | get_json_object('{"a":"v"}', '$.a') | traducir |
timezone-cast | dialecto | CAST(TIMESTAMP '…' AS VARCHAR) | CAST(TIMESTAMP '…' AS STRING) | traducir |
qualify | capacidad | SELECT n FROM t QUALIFY row_number() OVER (ORDER BY n) = 1 | SELECT n FROM (SELECT n, row_number() OVER (ORDER BY n) rn FROM t) WHERE rn = 1 | reescribir |
distinct-on | capacidad | SELECT DISTINCT ON (g) g, v FROM t ORDER BY g, v | SELECT g, v FROM (SELECT g, v, row_number() OVER (PARTITION BY g ORDER BY v) rn FROM t) WHERE rn = 1 | reescribir |
7 · Cómo se verifica este contrato
No se verifica leyéndolo. Se verifica corriéndolo:
| Gate | Contesta |
|---|---|
scripts/bridge/c0-pinneo.ts | ¿el motor sigue teniendo los valores de §1? |
scripts/bridge/c2-corpus-lectura.ts | ¿la LECTURA hace lo que el contrato dice? |
scripts/bridge/c3-corpus-escritura.ts | ¿la ESCRITURA hace lo que el contrato dice? |
scripts/bridge/c3-0-gate-rollback.ts | ¿el corpus puede deshacerse sin DROP? |
scripts/bridge/c1-gate-citado.ts | ¿el plan emite SQL que el motor parsea? |
npm run check:carbon-sql-contract | ¿este documento casa con el código? (en frío) |
Cobertura de los corpus
Lectura — 114 sondas · 18 ejes
| Eje | Sondas |
|---|---|
| agregacion | 9 |
| agrupacion | 4 |
| aritmetica | 7 |
| cast | 5 |
| comparacion | 9 |
| complejos | 5 |
| condicional | 5 |
| conjuntos | 5 |
| cte | 4 |
| join | 7 |
| json | 3 |
| lambda | 3 |
| orden | 5 |
| subconsulta | 4 |
| temporal | 9 |
| texto | 13 |
| tipos | 12 |
| ventana | 5 |
Escritura — 16 sondas · 6 ejes
| Eje | Sondas |
|---|---|
| aritmetica | 3 |
| asignacion | 3 |
| forma | 3 |
| merge | 2 |
| nulos | 3 |
| temporal | 2 |
⚠️ Esperados todavía provisional: 20. Un esperado provisional no
acusa al motor cuando discrepa: pide que alguien lo autorice. Se declara el número
porque es la medida honesta de cuánto de este contrato está verificado frente a
cuánto está supuesto.
tipo-doubletipo-binaryarit-div-entera-explicitatxt-concat-nulltxt-like-escapetxt-positiontmp-resta-fechastmp-date-trunctmp-intervalagg-filteragg-collect-listgrp-grouping-setsjoin-semijoin-anticte-recursivaord-desccplx-array-fuera-de-rangocplx-map-ausentejson-from-jsonlambda-aggregate
⚠️ Lo que los corpus NO cubren
- Concurrencia: dos escrituras simultáneas sobre la misma tabla no se prueban.
- El oráculo de gobierno (ledger) no se ejerce sonda a sonda; lo cubre
w3-c-gate-atribucion.tsaparte. - Volumen: todas las sondas son pequeñas. Nada dice qué pasa con millones de filas.
8 · Política de cambio de comportamiento
El bundle vigente es 2026_08. Un cambio en el motor o en cualquier valor
🔴 de §1 es un cambio de comportamiento y entra con nombre, no con un despliegue
silencioso. Ver docs/architecture/carbon-sql-contract-approach.md §4·4.
⚠️ Límite declarado: la ventana de prueba/opt-out al estilo Snowflake exige
correr dos versiones del motor a la vez, y hoy la cuota del cluster no da
(CPUS_ALL_REGIONS = 12). Se declara la política —que es lo que evita el cambio
silencioso— y la ventana llega con la cuota. Se dice en vez de prometerla.