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.mddonde 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 sí 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:
| Camino | Pasa por la cara | Quién aplica |
|---|---|---|
| DuckDB (el DML de hoy) | ⛔ NO — cero eventos en access_events | OpenFGA |
| 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:
| Sonda | Resultado |
|---|---|
SELECT * FROM main.test.animal_events | ✅ SUCCEEDED — la lectura está cerrada |
INSERT INTO main.test.animal_events … WHERE 1=0 | ⛔ ValidationException: 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 UNORDERED | ⛔ ForbiddenException |
⇒ 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
| Pregunta | Lo 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-sparkNO 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-etlllevaCATALOG_MANAGE_CONTENT⇒ implicaTABLE_DROPy
TABLE_CREATE. Un editor SQL que puede soltar cualquier tabla del inquilino, no.
·lakekeeper-spark-maintenancetiene 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 unaccess_eventde
compactación indistinguible de unDELETEde usuario.⇒ Identidad propia, rol
sql-editor=['reader', 'dml-writer'](composición, no
duplicación).dml-writerlleva sóloTABLE_WRITE_DATA+TABLE_WRITE_PROPERTIES.
SinTABLE_CREATEporque elCREATElo 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ía | Resultado |
|---|---|
SET spark.sql.catalog.carbon.snapshot-property.… | SUCCEEDED … y no aparece en el summary |
SET …catalog.carbon.write.snapshot-property.… | ídem |
SET spark.wap.id | SUCCEEDED … 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:
- ⭐ 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.
- ⭐ 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_eventsy el vending). - 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
directo —warehouse-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 sentido | la 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 snapshotId | withDmlControlPlane 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 sí, 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 repara — FALSADO el 2026-08-10, en vivo
WRITE UNORDERED reparaLa 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 (SerializableTable →
SortOrderParser.fromJson → UnboundSortOrder.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):
- leer el
metadata.jsonvigente de la tabla desde R2; - eliminar el sort-order huérfano de la lista y escribir un metadata nuevo;
- re-apuntar el catálogo (
register_table, o el CAS del catálogo).
⚠️ Tres cosas que hay que resolver antes de tocar nada:
register_tablesobre una tabla existente puede exigirDROPprimero — y el editor no lo tiene a propósito (D2). Haría faltalakekeeper-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).
| Fase | Gate | |
|---|---|---|
| 🏁 W0 | ⭐ Principal 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 |
| 🏁 W1 | ⭐ Los 10 sort orders reparados — HECHO 2026-08-10 | ✅ barrido: 0 huérfanos en 119 tablas · historial intacto (11 y 3 snapshots originales) · las 10 admiten INSERT |
| W2 | El brazo de escritura del puente — sparkEngine.write, elegirMotorDeLectura(write) → spark | un INSERT real por el editor deja un snapshot con carbon.operation-id · y attributable: true en el ledger |
| W3 | Idempotencia — carbon.operation-id como WAP.id (D3) | ejecutar dos veces la misma operación deja UNA fila · las txn open se cierran solas |
| W4 | CREATE 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 |
| W5 | Limpieza — 10 txn colgadas (ratifyOpenTransactions) + 32 tablas huérfanas | dataset_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 escritura | El 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 escritura | Dos 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 security | La política llega hasta TABLA. Un UPDATE que sólo debería tocar ciertas filas no se puede acotar hoy |
| La branch de WAP completa | Diferida a propósito (D3). Cuando existan expectations |
| Las 32 tablas huérfanas | W5 las censa; qué hacer con ellas —adoptar o borrar— es decisión del owner, no de este plan |
7 · Riesgos, y cuál vigilar
- ⭐⭐
WRITE UNORDEREDno 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. - Dar escritura a
lakekeeper-sparkamplía el radio de ese principal. Se acepta con D2, pero conviene recordar queBRIDGE_JWT_SECRETes 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. - El editor pasa a poder destruir datos. Hasta hoy la escritura era de DuckDB por
otro camino; a partir de W2 un
DELETEdel editor va por la puerta. ElwriteTargetdeclarado (no parseado del SQL) sigue siendo el cerrojo, y hay que mantenerlo. - La deriva Index↔físico crece si W4 falla a medias: un
CREATEque registre el dataset y luego no cree la tabla física deja una fila fantasma. W4 necesita compensación, ycompensate.tsya existe.
8 · Documentos
| Doc | Para qué |
|---|---|
⭐ sustrato-03.md | la foto completa. Manda sobre esto |
b-spark-bridge-approach.md | el puente · §B4 queda sustituido por este documento |
s5-write-maintenance-approach.md | el mantenimiento Iceberg (B7), que comparte los grants de W0 |