Published

W · El camino de ESCRITURA del SQL Editor — approach

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

W · El camino de ESCRITURA del SQL Editor — approach

Fecha: 2026-08-10. Sucede a b-spark-bridge-approach.md
§B4, que planteó «el brazo de escritura» como una fase más del puente. No lo es.
Este documento existe porque al medirlo aparecieron cuatro bloqueantes que ese plan
no contemplaba, y uno de ellos —los sort orders huérfanos— no está en ningún sitio.

Regla, la de siempre: cada hecho lleva el comando que lo demuestra. Lo que no lo
lleva va marcado como decisión (§4) o como riesgo (§7).

Manda sustrato-03.md donde discrepen.


1 · Qué se construye, y por qué no es «dejar escribir a Spark»

El DML del SQL Editor hoy NO es atribuible. La última transacción del editor en el ledger:

{"sink":"iceberg_native","engine":"duckdb","writer":"sql-editor.dml","attributable":false}

attributable: false significa que el dato y su procedencia se pueden desincronizar: DuckDB no firma el commit, así que nadie puede probar después qué operación produjo qué snapshot. ratify.ts ya tiene un veredicto para eso — unverifiable — y lo emite porque no le queda otra.

Spark sabe hacerlo. S5·1 lo cerró: las opciones snapshot-property.* viajan dentro del mismo commit que los datos, y el ledger lo refleja:

{"sink":"iceberg_native","engine":"spark","writer":"s5-1-gate","attributable":true}

⇒ Migrar la escritura del editor a Spark no es un cambio de motor: es cerrar el agujero de procedencia y, de paso, mover el punto de aplicación de la política.

⚠️ Y ése es el cambio de verdad. Hoy hay dos caminos con dos aplicadores:

CaminoPasa por la caraQuién aplica
DuckDB (el DML de hoy)NO — cero eventos en access_eventsOpenFGA
Spark (lo que se propone)✅ síIndex

2 · Lo MEDIDO — el estado real, no el documentado

# el DML actual no es atribuible, y el ledger tiene entradas colgadas
select type, status, count(*) from dataset_transactions group by 1,2;
#   SNAPSHOT/committed 868 · APPEND/open 9 · UPDATE/open 6 · …  ⇒ 10 abiertas, la más vieja del 01-08

# NINGÚN principal de lectura tiene privilegios de escritura
select principal_id, privilege from principal_grants where privilege like '%WRITE%';
#   lakekeeper-spark-maintenance → TABLE_WRITE_DATA · TABLE_WRITE_PROPERTIES
#   lakekeeper-spark             → (ninguno)
#   lakekeeper-duck-server       → (ninguno)   ← y aun así escribe: no pasa por la cara

Las cuatro sondas contra el sistema vivo, por el puente:

SondaResultado
SELECT * FROM main.test.animal_eventsSUCCEEDED — la lectura está cerrada
INSERT INTO main.test.animal_events … WHERE 1=0ValidationException: Cannot find source column for sort field: identity(1) ASC NULLS LAST
CREATE TABLE main.test.__probe (id INT)Forbidden: … no puede loadTable (**unknown-table**)
ALTER TABLE … WRITE UNORDEREDForbiddenException

La escritura no falla por una razón: falla por tres, y en cascada.

⛔⛔ El bloqueante que no estaba en ningún documento

Diez tablas tienen un SORT ORDER HUÉRFANO. El sort order apunta al field-id 1 — la columna de identidad legacy que retiró scripts/duckdb/drop-legacy-identity-columns.ts— y esa columna ya no existe en el schema.

tablas inspeccionadas : 119
con sort order huérfano: 10   (todas source-id 1)
   main.default.caretakers · main.default.fighter_jets · main.test.diets ·
   main.test.animal_events · main.test.bronze_sellers · main.test.censo_poblacional · …

La lectura funciona y la escritura no, porque Spark sólo vincula el sort order al escribir. Por eso el problema llevaba meses invisible: ningún gate de lectura podía verlo.

Y es un defecto conocido de Iceberg —apache/iceberg#8614, «Dropping a previous Iceberg sort order field causes ValidationException»cerrado como «not planned», igual que el de la renovación de tokens. Dos bugs upstream sin dueño en el mismo camino.

Y la deriva, ahora cuantificada

32 tablas físicas sin dataset en Index (403 unknown-table al cargarlas). El sustrato la mencionaba en cualitativo; ahora tiene número.


3 · Lo que dice la industria

PreguntaLo que hace el sector
¿Quién es el escritor: el usuario o el servicio?El servicio. Unity Catalog usa «an internal warehouse identity, not the user identity» para refrescos gobernados. La trazabilidad no vive en la credencial de storage: vive en el log del catálogo, que registra cada vend con «which engine, which table, which operation, at what time».
¿Dónde se aplica la política?Una vez, en el catálogo. «Governance is applied once at the catalog level, and any engine accessing the catalog adheres to the same rules» — que es exactamente lo que la cara ya hace para lectura.
¿Cómo se hace una escritura gobernada y atómica?Write-Audit-Publish sobre branches de Iceberg. Se escribe a una branch aislada, se audita y se publica con fast-forward o cherry-pick: metadata-only, atómico, zero-copy. ⭐ «Apache Spark is the only compute engine that fully supports this pattern.»
¿Y la idempotencia de un reintento?WAP.id: la operación se marca ANTES de ejecutar; Iceberg reconoce el id y no duplica.
¿Quién crea la tabla?El catálogo. En Unity Catalog CREATE TABLE exige permiso en el schema padre y es el catálogo quien crea el directorio con nombre generado. El usuario pone el nombre lógico; el catálogo, el físico.

4 · Las decisiones, con su porqué

⭐ D1 · El escritor es un PRINCIPAL DE SERVICIO, y la política es de Index

No hay identity passthrough. El sustrato ya lo descartó (§5: «la cara es un proxy de confianza») y el estándar lo respalda: la auditoría no depende de que el token de storage lleve el nombre del usuario, sino de que el catálogo registre quién pidió qué. Eso ya existe: access_events + carbon.operation-id en el snapshot.

⭐ D2 · Una IDENTIDAD PROPIA para el editor — lakekeeper-spark-editor

⚠️ Corregida antes de ejecutar. La primera versión decía «dar escritura a
lakekeeper-spark». Al ir a hacerlo apareció s5-0-principals-gate.ts, que afirma
como gate
que «lakekeeper-spark NO puede escribir» — y no es una formalidad:
S2/S4 lo usan como control negativo, así que dárselo habría hecho que esos gates
pasaran a medir otra cosa sin que nadie lo notara. Se descartan también las otras
dos identidades existentes:

· lakekeeper-spark-etl lleva CATALOG_MANAGE_CONTENT ⇒ implica TABLE_DROP y
TABLE_CREATE. Un editor SQL que puede soltar cualquier tabla del inquilino, no.
· lakekeeper-spark-maintenance tiene el conjunto casi exacto — y aun así no
sirve: su propio comentario dice que la frontera del mantenimiento «no vive en el
privilegio: vive en la IDENTIDAD»
. Reusarla dejaría un access_event de
compactación indistinguible de un DELETE de usuario.

Identidad propia, rol sql-editor = ['reader', 'dml-writer'] (composición, no
duplicación). dml-writer lleva sólo TABLE_WRITE_DATA + TABLE_WRITE_PROPERTIES.
Sin TABLE_CREATE porque el CREATE lo materializa la cara (D4), y darlo al motor
abriría un segundo camino de creación — que es justo lo que produce la deriva
Index↔físico ya medida.

D2 original · un principal para leer y escribir — y el menor privilegio POR DATASET

El puente sostiene una sesión por inquilino y una sesión Spark tiene un principal. Las alternativas —dos catálogos en la sesión, o dos sesiones— o ensucian el SQL del usuario (INSERT INTO carbon_w…) o duplican el coste de sesión fría, que es el 47× que manda sobre el diseño del puente.

lakekeeper-spark recibe TABLE_WRITE_DATA + TABLE_WRITE_PROPERTIES. El control de quién escribe qué NO se hace por principal: se hace por dataset en Index, que es donde vive la política real y donde ya se aplica la lectura.

⚠️ Esto ROMPE a propósito el criterio que se usó para la lectura («paridad exacta con el motor reemplazado»). Allí valía porque DuckDB leía por la cara. Aquí no aplica: DuckDB escribe sin pasar por la cara, así que no hay paridad que copiar. El criterio correcto aquí es el del catálogo: privilegio al conducto, política al objeto.

⭐⭐ D6 · LA FIRMA LA PONE LA CARA, no el motor (2026-08-10, medido en W2)

El problema: S5·1 firmó el commit con DataFrameWriterV2:

df.writeTo(fqn).option("snapshot-property.carbon.operation-id", op).append()

Pero el editor manda SQL, y el puente hace spark.sql(...). Con SQL puro no hay .option(). Se probaron tres vías contra el sistema vivo y ninguna firma:

VíaResultado
SET spark.sql.catalog.carbon.snapshot-property.…SUCCEEDED … y no aparece en el summary
SET …catalog.carbon.write.snapshot-property.…ídem
SET spark.wap.idSUCCEEDED … y tampoco (necesita write.wap.enabled en la tabla)

El summary del snapshot resultante lleva operation, engine-name, spark.app.id, iceberg-version… y nada nuestro.

⇒ Existe la API correcta —CommitMetadata.withCommitProperties, que envuelve una ejecución SQL y añade propiedades en el mismo commit— pero es Java/Scala, y el puente es Python sobre Spark Connect, que no expone la JVM. Inalcanzable desde donde estamos.

⇒ La salida, y es mejor que la original

El commit PASA POR LA CARA. updateTable es una de las operaciones que routeCatalogRequest ya gobierna: el motor manda el commit y la cara lo reenvía a Lakekeeper. ⇒ La cara puede inyectar carbon.operation-id en el summary del snapshot antes de reenviarlo.

Tres razones por las que esto es superior a firmar en el motor:

  1. Vale para CUALQUIER motor, no sólo Spark. DuckDB, Karma o PyIceberg quedan firmados igual, sin tocarlos — la misma lógica que llevó la resolución de nombres al catálogo el 08-10.
  2. La cara es el único sitio que sabe quién pregunta. El motor no conoce al usuario; la cara sí (es lo que ya usa para access_events y el vending).
  3. Sigue siendo el MISMO commit: la cara no hace un segundo commit, modifica el cuerpo del que ya viaja. La propiedad de S5·1 —el dato y su procedencia no se pueden desincronizar— se conserva entera.

⚠️ Y lo que hay que resolver: el cuerpo del updateTable lleva los snapshot-id de 64 bits, así que hay que tocarlo con parseLossless, nunca con JSON.parse — es exactamente el bug que ya costó un bloqueante de escritura entero (…768 → …700 en silencio, y CatalogCommitConflicts sin concurrencia alguna).

⛔⛔ D7 · W3 · EL ID NO SE PUEDE PROPAGAR HASTA EL COMMIT DE SPARK (medido)

El problema, que W2 dejó abierto sin verlo: ratify.ts busca el snapshot cuyo carbon.operation-id sea el id de la transacción del ledger:

status = await getOperationStatus(txn.dataset_id, txn.id)

Pero en W2 la cara genera un uuid propio, así que nunca casa ⇒ las transacciones se quedarían open para siempre. Que es justo el estado medido: 10 colgadas, la más vieja del 01-08.

⇒ W3 exige que el id que firma la cara sea el transactionId de la puerta. Se probó la vía obvia —propagarlo como cabecera del catálogo— y no funciona:

SET `spark.sql.catalog.carbon.header.x-carbon-operation-id` = w3-probe-…   → SUCCEEDED
INSERT …                                                                   → SUCCEEDED
carbon.operation-id del snapshot  →  ac624963-…  (el uuid de la CARA, no la marca)

Spark fija las cabeceras del catálogo al CREAR la sesión, no por petición — y la sesión es caliente y compartida por inquilino, así que no se puede reconfigurar sin pagar el 47× en cada query. Es la cuarta vez en la jornada que un SET devuelve SUCCEEDED sin hacer nada.

Lo que SÍ queda de este intento: la cara acepta x-carbon-operation-id y firma con ella si viene bien formada. No sirve para Spark, pero sí para todo cliente HTTP directowarehouse-writer, el mantenimiento— que hoy no tenía forma de declarar su operación. El respaldo (uuid propio) se queda para que nadie escriba sin firma.

⇒ Las dos vías que quedan para W3, sin probar

Invertir el sentidola puerta ya LEE el snapshot tras el commit (getTableStatus en withDmlControlPlane). Que guarde en el ledger el carbon.operation-id que encuentre, y que ratify verifique por ese campo en vez de por txn.id. Cierra el círculo sin propagar nada. ⚠️ Si el proceso muere en esa ventana, la txn queda sin id — pero no es peor que hoy
Verificar por snapshotIdwithDmlControlPlane ya registra el snapshot observado. ratify podría preguntar por él en vez de por la marca. Más directo, pero pierde la propiedad de S5·1: el snapshot-id se observa DESPUÉS, así que dato y procedencia vuelven a poder desincronizarse

⇒ La primera conserva la invariante; la segunda es más simple y la rompe. No se elige aquí: W3 empieza midiendo cuánto dura realmente esa ventana.

⭐ D3 · WAP no para el DML interactivo — pero WAP.id , y por otra razón

Un INSERT de un usuario en un editor no necesita una fase de auditoría: añadiría latencia y una branch por sentencia a cambio de nada.

Pero hay un problema real que WAP.id resuelve y que ya está medido: las 10 transacciones open colgadas. Hoy withDmlControlPlane abre la transacción, ejecuta y la cierra; si el proceso muere entre el commit de Iceberg y el cierre del ledger, queda una entrada abierta — y el dato está escrito. Eso es precisamente lo que se ve en la tabla.

⇒ Se reusa el mismo carbon.operation-id que ya viaja en el snapshot como WAP.id. Una sola marca que sirve para las dos cosas: atribuir (S5·1) y deduplicar un reintento. Sin ledger nuevo, sin branch por sentencia.

⭐ WAP completo (branch + audit + publish) se reserva para cuando existan las expectations de calidad. Entonces será el mecanismo correcto — no ahora.

⭐ D4 · El CREATE TABLE lo materializa LA CARA

Hoy createTable muere con unknown-table porque la cara exige que el dataset exista en Index. La pieza que falta es el orden inverso, y es el de Unity Catalog:

motor: CREATE TABLE main.default.ventas
   → la cara autoriza CREATE en el SCHEMA padre (no en la tabla, que no existe)
   → la cara CREA el dataset en Index  ⇒ obtiene su uuid
   → reenvía a Lakekeeper como `ds_<uuid>`      (el nombre físico lo pone el catálogo)
   → devuelve al motor el nombre LÓGICO

Es la misma asimetría que ya resolvió la lectura el 08-10: el usuario nombra, el catálogo identifica.

⛔⛔ D5 · WRITE UNORDERED reparaFALSADO el 2026-08-10, en vivo

La primera versión de este documento proponía ALTER TABLE … WRITE UNORDERED como reparación, marcándolo como riesgo nº1 por no estar confirmado por ninguna fuente. Se probó en cuanto W0 lo desbloqueó. No repara.

ALTER TABLE main.test.censo_poblacional WRITE UNORDERED   →  SUCCEEDED
INSERT INTO  main.test.censo_poblacional … WHERE 1=0      →  ValidationException (el MISMO error)

El comando no falló y no hizo nada, que es la trampa que este repo ya tiene anotada: no basta con que el comando no falle, hay que mirar el RESULTADO.

La causa, leyendo la metadata en vez de suponiéndola:

censo_poblacional   default-sort-order-id: 0   sort-order 0: VACÍO   sort-order 1: [(1,'identity')]
diets               default-sort-order-id: 0   sort-order 0: VACÍO   sort-order 1: [(1,'identity')]

Las tablas YA estaban en default-sort-order-id: 0. El ALTER no cambió nada porque no había nada que cambiar: el sort order por defecto nunca fue el problema.

El problema es que el sort-order 1 roto SIGUE EN LA LISTA sort-orders, e Iceberg parsea todos al serializar la tabla para los executors (SerializableTableSortOrderParser.fromJsonUnboundSortOrder.bind). Por eso el error aparece en el executor y no en el driver, y por eso la lectura sobrevive: no serializa igual.

⚠️ sort-orders es append-only en la spec: NO existe SQL que borre uno histórico, ni procedimiento estándar en Iceberg. Confirmado buscando: sólo hay register_table y rewrite_table_path, que son de recuperación.

⇒ D5·bis · W1 pasa a ser CIRUGÍA DE METADATA

Camino propuesto (no ejecutado; W1 empieza por diseñarlo con su gate):

  1. leer el metadata.json vigente de la tabla desde R2;
  2. eliminar el sort-order huérfano de la lista y escribir un metadata nuevo;
  3. re-apuntar el catálogo (register_table, o el CAS del catálogo).

⚠️ Tres cosas que hay que resolver antes de tocar nada:

  • register_table sobre una tabla existente puede exigir DROP primero — y el editor no lo tiene a propósito (D2). Haría falta lakekeeper-operator, lo que convierte W1 en una operación de mantenimiento, no de producto.
  • Es reversible: el metadata anterior no se borra. Pero el CAS del catálogo sí es un punto de fallo — hay que hacerlo tabla a tabla, no en lote.
  • Empezar por la de 4 filas (censo_poblacional) sigue siendo válido, y ahora con más razón.

5 · Las fases, con su gate

Cada gate enumera sus salidas válidas y sale con código propio; ninguno aprueba por ausencia de error (§12·1 del sustrato).

FaseGate
🏁 W0Principal lakekeeper-spark-editor (D2) — HECHO 2026-08-10✅ 4/4: la lectura no se rompió · ALTER deja de dar Forbidden · el editor NO puede DROP · sigue sin ver otro inquilino
🏁 W1Los 10 sort orders reparadosHECHO 2026-08-10✅ barrido: 0 huérfanos en 119 tablas · historial intacto (11 y 3 snapshots originales) · las 10 admiten INSERT
W2El brazo de escritura del puentesparkEngine.write, elegirMotorDeLectura(write) → sparkun INSERT real por el editor deja un snapshot con carbon.operation-id · y attributable: true en el ledger
W3Idempotenciacarbon.operation-id como WAP.id (D3)ejecutar dos veces la misma operación deja UNA fila · las txn open se cierran solas
W4CREATE TABLE por la cara (D4)crear desde el editor produce dataset en Index y tabla física, con el nombre lógico del usuario · y el negativo: sin permiso en el schema, denegado
W5Limpieza — 10 txn colgadas (ratifyOpenTransactions) + 32 tablas huérfanasdataset_transactions where status='open' → 0 · y el censo Index↔físico cuadra

W0 y W1 son prerrequisitos duros de todo lo demás. W2 no puede ni intentarse antes.

El orden NO es negociable, y está medido

ALTER TABLE … WRITE UNORDERED   →  Forbidden        (necesita W0)
INSERT INTO …                   →  ValidationException  (necesita W1)
CREATE TABLE …                  →  unknown-table    (necesita W4)

Cada uno tapa al siguiente. Intentar W2 antes de W1 sólo produce el error de W1 otra vez.


6 · Lo que este plan NO resuelve — dicho antes, no después

El dialecto de escrituraEl DML del editor pasa de DuckDB SQL a Spark SQL. MERGE INTO existe en los dos, pero la semántica ANSI de Spark 4 lanza donde DuckDB devolvía null. Hay que medirlo con el corpus, igual que B5 midió la lectura
La concurrencia de escrituraDos usuarios escribiendo la misma tabla ⇒ el CAS de Lakekeeper rechaza uno. Hoy no hay reintento con backoff en el camino del editor
Row/column-level securityLa política llega hasta TABLA. Un UPDATE que sólo debería tocar ciertas filas no se puede acotar hoy
La branch de WAP completaDiferida a propósito (D3). Cuando existan expectations
Las 32 tablas huérfanasW5 las censa; qué hacer con ellas —adoptar o borrar— es decisión del owner, no de este plan

7 · Riesgos, y cuál vigilar

  1. ⭐⭐ WRITE UNORDERED no está confirmado como reparación por ninguna fuente oficial — el issue upstream está cerrado sin arreglo y la documentación no lo afirma. W1 empieza probándolo en UNA tabla (main.test.censo_poblacional, 4 filas) antes de tocar las otras nueve. Si no repara, el plan B es recrear la tabla, que es caro y hay que saberlo pronto.
  2. Dar escritura a lakekeeper-spark amplía el radio de ese principal. Se acepta con D2, pero conviene recordar que BRIDGE_JWT_SECRET es simétrico y sigue en la lista de secretos por rotar: quien lo tenga acuña tokens para cualquier inquilino, y a partir de W0 esos tokens también escriben.
  3. El editor pasa a poder destruir datos. Hasta hoy la escritura era de DuckDB por otro camino; a partir de W2 un DELETE del editor va por la puerta. El writeTarget declarado (no parseado del SQL) sigue siendo el cerrojo, y hay que mantenerlo.
  4. La deriva Index↔físico crece si W4 falla a medias: un CREATE que registre el dataset y luego no cree la tabla física deja una fila fantasma. W4 necesita compensación, y compensate.ts ya existe.

8 · Documentos

DocPara qué
sustrato-03.mdla foto completa. Manda sobre esto
b-spark-bridge-approach.mdel puente · §B4 queda sustituido por este documento
s5-write-maintenance-approach.mdel mantenimiento Iceberg (B7), que comparte los grants de W0