Published

Carbon SQL v1 — el contrato

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 v1 — el contrato

⚠️ ESTE DOCUMENTO SE GENERA. No lo edites: los cambios se pierden en la
siguiente corrida. La fuente es lib/warehouse/carbon-sql-contract.ts y 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.

ClaveValorGobierna
🔴spark.sql.ansi.enabledtrueerrores en tiempo de ejecución en vez de null silencioso
🔴spark.sql.storeAssignmentPolicyANSI⭐ 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.doubleQuotedIdentifiersfalse⭐ que "x" sea un LITERAL DE CADENA y no un identificador
spark.sql.ansi.enforceReservedKeywordsfalseque las palabras reservadas ANSI no se impongan
🔴spark.sql.session.timeZoneGMTla semántica de todo timestamp
🔴spark.sql.caseSensitivefalsela resolución de identificadores
🔴spark.sql.legacy.timeParserPolicyCORRECTEDque una fecha inválida lance en vez de deslizarse
spark.sql.ansi.relationPrecedencefalsela precedencia de JOIN frente a la coma
🔴spark.sql.sources.partitionOverwriteModeSTATICla 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

Identificadorbacktick
Literal de cadenacomilla 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

VerboDesde
SELECT / WITHv1
UPDATEv1
DELETEv1⚠️ ver la divergencia delete-igualdad-negada
MERGE INTOv1
CREATE TABLEv1el destino se materializa en Index por la cara
CREATE TABLE … AS SELECTv1

Fuera — cada uno con su motivo

QuéPor qué no está
INSERT … SELECTFuera 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 TABLEA 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.
QUALIFYSpark no lo tiene (DuckDB sí). Se escribe con una subconsulta sobre row_number(). Divergencia de CAPACIDAD, medida.
Vistas materializadasNo existen en la plataforma todavía.
Time travel en SQLLos 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é decideCuándo
colocacióndónde nace un dataset al que nadie dio coordenadaal escribir
resoluciónqué significa un nombre peladoal 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éValorDónde
Objetos del Warehouse leídos por principal5.000catalog-sql/grammar.ts · TOPE_OBJETOS
Filas de una relación inline (information_schema)20.000catalog-sql/inline-relation.ts · TOPE_FILAS_INLINE
Espera máxima de una sentencia50 sservices/spark-bridge · wait_timeout_s“
Anidamiento de vistas12warehouse/query/duck-plan.ts · MAX_VIEW_DEPTH
Tamaño del resultadoINLINE — sin EXTERNAL_LINKSpendiente (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. 92233720368547758079223372036854776000. 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 se delatan solas (la query no corre), salvo las marcadas semantica. Se publican para que nadie las descubra en producción.

FunciónClaseSe escribía asíEn Carbon SQLQué se hace
null-orderingsemanticaORDER 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-arithmeticsemanticaDATE '2026-01-02' - DATE '2026-01-01' → 1datediff(DATE '2026-01-02', DATE '2026-01-01') → 1rechazar
array-functionsdialectolen([1, 2, 3])size(array(1, 2, 3))traducir
struct-accessdialecto{'a': 1}.anamed_struct('a', 1).atraducir
regexdialectoregexp_replace('a1b2', '[0-9]', 'X', 'g')regexp_replace('a1b2', '[0-9]', 'X')traducir
json-accessdialectojson_extract_string('{"a":"v"}', '$.a')get_json_object('{"a":"v"}', '$.a')traducir
timezone-castdialectoCAST(TIMESTAMP '…' AS VARCHAR)CAST(TIMESTAMP '…' AS STRING)traducir
qualifycapacidadSELECT n FROM t QUALIFY row_number() OVER (ORDER BY n) = 1SELECT n FROM (SELECT n, row_number() OVER (ORDER BY n) rn FROM t) WHERE rn = 1reescribir
distinct-oncapacidadSELECT DISTINCT ON (g) g, v FROM t ORDER BY g, vSELECT g, v FROM (SELECT g, v, row_number() OVER (PARTITION BY g ORDER BY v) rn FROM t) WHERE rn = 1reescribir

7 · Cómo se verifica este contrato

No se verifica leyéndolo. Se verifica corriéndolo:

GateContesta
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

EjeSondas
agregacion9
agrupacion4
aritmetica7
cast5
comparacion9
complejos5
condicional5
conjuntos5
cte4
join7
json3
lambda3
orden5
subconsulta4
temporal9
texto13
tipos12
ventana5

Escritura — 16 sondas · 6 ejes

EjeSondas
aritmetica3
asignacion3
forma3
merge2
nulos3
temporal2

⚠️ 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-double
  • tipo-binary
  • arit-div-entera-explicita
  • txt-concat-null
  • txt-like-escape
  • txt-position
  • tmp-resta-fechas
  • tmp-date-trunc
  • tmp-interval
  • agg-filter
  • agg-collect-list
  • grp-grouping-sets
  • join-semi
  • join-anti
  • cte-recursiva
  • ord-desc
  • cplx-array-fuera-de-rango
  • cplx-map-ausente
  • json-from-json
  • lambda-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.ts aparte.
  • 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.