El sustrato de ESCRITURA — debilidades, medidas
Fecha: 2026-08-12. Antes de seguir añadiendo verbos: qué le falta al plano de
escritura para ser maduro, y por qué añadir un verbo gobernado cuesta tanto hoy.Regla de la casa: cada hecho lleva el comando que lo demuestra.
Alimenta a
plano-gobernado-approach.mdy a
parser-al-motor-spec.md. La interacción de las
POLÍTICAS con la escritura es una iteración aparte y no se trata aquí.
⚠️ INCISO 2026-08-12 · este documento se escribió ANTES de decidir absorber
Gravitino. Releído desde el mapa
(index-gobernanza-mapa.md§8·septies), el veredicto
es: seis de las siete debilidades NO las toca el cambio de catálogo —son del
cliente, del ejecutor o del parser, no del sitio donde se commitea—.⭐⭐ La séptima sí, y es la grande: §4·4 (fallo parcial con reconciliación
manual). Existe porque el commit y su asiento viven en dos bases. Si el
catálogo pasa a compartir Postgres con Index —en la misma región—, el commit y
su asiento caben en una transacción y el agujero se cierra de raíz, sin
inventar un protocolo de dos fases.⇒ No es una debilidad de escritura: es una consecuencia de la topología.
1 · La pregunta
«¿La industria unifica “esto escribe” / “sé leer su destino” / “este verbo está
admitido”, y nosotros lo estamos partiendo? ¿Por qué añadir verbos gobernados nos
genera tanta fricción?»
Sí lo unifican, y sí lo estamos partiendo — pero el diagnóstico de fondo es otro.
2 · Lo que hace la industria
Trino expone un método por operación, y cada uno recibe el objeto:
checkCanInsertIntoTable(context, tabla)
checkCanDeleteFromTable(context, tabla)
checkCanAddColumn(context, tabla)
⇒ La pregunta nunca es «¿está permitido este verbo?» sino «¿puede ESTE principal hacer ESTA operación sobre ESTE objeto?». No existe una lista global de verbos permitidos en Trino, ni en Spark, ni en Snowflake. La operación se deriva del plan analizado y el permiso se comprueba contra el objeto.
Una sola derivación, N comprobaciones:
plan analizado → operación → privilegio requerido → ¿lo tiene sobre ESE objeto?
3 · ⭐⭐⭐ El diagnóstico: nuestras tres listas son TRES SUCEDÁNEOS de lo mismo
| Lista nuestra | Qué pregunta | De qué es sucedáneo |
|---|---|---|
classifyForRouting | ¿esto escribe? | del plan — sin plan, se adivina del texto |
locateDmlCommand | ¿dónde está su destino? | del plan — sin plan, se busca con una regex |
DML_VERBS_PERMITIDOS | ¿este verbo está admitido? | ⭐ de los grants de usuario |
Las dos primeras ya tienen sustituto construido (P·0-P·3: el plan da verbo y destino). La tercera es la que explica la fricción de verdad, y no es un problema de parsing:
⛔
DML_VERBS_PERMITIDOSexiste PORQUE no hay grants de usuario. No pudiendo
autorizar «este usuario puede borrar de esta tabla», se prohíbe el verbo para todo
el mundo. Es una prohibición global que sustituye a una decisión por objeto.
⇒ Por eso cada verbo nuevo cuesta tanto: abrirlo es una decisión de plataforma, no una
concesión de permiso. En la industria, admitir DELETE no es una decisión — es dar
MODIFY sobre una tabla a quien corresponda. Aquí es cambiar un Set que afecta a
todos los inquilinos a la vez, y por eso da miedo y por eso se documenta con párrafos.
La consecuencia práctica: la lista de verbos no se retira con P·4 (borrar
locateDmlCommand). Se retira cuando la retícula funciona para usuarios — es decir,
los grants de usuario son también el desbloqueo de los verbos, no sólo de las
políticas.
4 · Las debilidades del plano de escritura, medidas
Ordenadas por lo que impide construir encima.
4·1 · ⛔⛔ Una escritura sólo puede tocar UNA tabla
writeTarget?: string; // contract.ts — singular
Y el catálogo YA soporta el commit multi-tabla atómico: en la lista de endpoints que midió F·0 aparece
POST /v1/{prefix}/transactions/commit
y no lo usa nadie (grep en lib/, app/, services/: cero resultados).
⇒ Es la limitación que más pesa para el norte: una Action que escriba dos objetos no puede ser atómica. La capacidad está comprada y sin estrenar.
4·2 · 🏁 Sí hay reintento — pero era heredado, y nadie lo sabía (medido 2026-08-14)
Iceberg usa control de concurrencia optimista: los commits concurrentes se validan contra el estado sobre el que se construyeron, y el perdedor debe reintentar.
⛔⛔ Esta sección decía: «no hay ningún bucle de reintento en nuestro camino ⇒ dos
escrituras concurrentes: una falla y se la come el usuario». La primera mitad es cierta;
la segunda es FALSA, y estuvo a punto de costar un bucle propio construido al lado de uno
que ya funcionaba.
Quien commitea es Spark, y el cliente Iceberg ya reintenta ante un
CommitFailedException. La cara está en medio del commit y reenvía el 409 íntegro, así
que el mecanismo del estándar llega vivo hasta el motor. Medido:
un commit sobre un snapshot obsoleto → HTTP 409 · CommitFailedException (s5-0)
12 escritores concurrentes → 34 conflictos REALES · 12/12 acaban
→ +12 filas exactas: ni una perdida (s5-gate)
⭐ Lo que sí faltaba no era el bucle: era DECIRLE cuánto aguantar. Los cuatro
parámetros del reintento venían del defecto del motor (commit.retry.num-retries = 4).
Medido: con 8 escritores se agotan y el error llega al usuario. Ahora se declaran en la
tabla (lib/governance/table-robustness.ts) ⇒ cualquier motor los hereda.
⚠️ Y lo que NO se puede afirmar: que subir los reintentos arregle el caso de 8. El resultado es intermitente —8/8, 7/8, 8/8— y una de esas caídas fue con las propiedades ya puestas. La insistencia pasó de defecto heredado a decisión escrita; la mejora está sin probar.
4·3 · 🏁 El nivel de aislamiento, ahora DECLARADO (medido 2026-08-14)
Iceberg ofrece serializable y snapshot isolation, con compromisos distintos de contención. La queja era exacta: no elegíamos ninguno. Comprobado tabla por tabla — ninguna declaraba una sola de las siete propiedades.
⇒ Se declaran las tres write.*.isolation-level al valor que ya regía (serializable).
Declarar lo que ya pasa no cambia el comportamiento: lo convierte en una decisión
consciente y visible, que era justo lo que faltaba.
⏭️ Relajarlo a snapshot sigue siendo una decisión abierta, y es la palanca real contra
la contención: reduce conflictos y debilita la garantía. Cuando se tome, se cambia el mapa
y se vuelve a medir con s5-gate-reintento-occ.ts.
4·4 · ⚠️ Un agujero de fallo parcial, conocido y con reconciliación MANUAL
ControlPlaneUnrecordedError:
«el snapshot SE COMMITEÓ pero el control-plane no pudo registrarlo …
la txn queda 'open' — pendiente de reconciliación.
NO reintentar a ciegas: los datos ya están escritos.»
Está bien identificado y bien dicho —mucho mejor que la media— pero la reconciliación
la hace una persona. Existe compensate (el 4º verbo del JOP), que es corrección
a posteriori y visible, no un reintento automático.
4·5 · ⚠️ Una sentencia por ejecución
«Una sola sentencia por ejecución (salvo un lote de
ALTER TABLE … RENAME TO).»
⇒ No hay transacción multi-sentencia. Combinado con §4·1, el sustrato hoy no puede sostener «esta Action escribe A y B, o no escribe nada» — que es la definición misma de una Action.
4·6 · 🟡 El verbo se decide por la PRIMERA PALABRA del texto
dmlVerb(sql) toma sql.trimStart().match(/^[A-Za-z]+/). Por eso ALTER TABLE … ADD COLUMN y ALTER TABLE … RENAME TO —que son cosas distintas, una del motor y otra del
catálogo— colapsan en ALTER. Ya se sorteó en P·5 preguntándole al plan, pero la
función sigue ahí y sigue siendo la que decide en el resto de caminos.
4·7 · 🟡 Dos guardianes con la condición de retirada CADUCADA, en una sola sesión
ALTER— «exige pasar por el contrato de esquema de Index»: el contrato ya existe ⇒ retirado el 2026-08-12.INSERT— «su fuente no se resuelve»: medido en frío, la fuente SÍ se resuelve (le sale su CTE al namespace físico).
⇒ Dos en una sesión no es casualidad: la lista de verbos prohibidos necesita una relectura entera, no verbo a verbo. Cada entrada debería llevar su condición de retirada y la fecha en que se comprobó por última vez.
4·bis · ⭐⭐⭐ LA RELECTURA (2026-08-14) — reconstruida desde Postgres
§4·7 pedía «una relectura entera de la lista, no verbo a verbo», con condición de
retirada y fecha de comprobación por entrada. Esto es esa relectura, y no se apoya en
ningún documento: sale de la base (v0-relectura-desde-postgres.ts,
v1-alcance-y-verbos.ts) y del runtime.
⛔⛔ Lo primero, porque invalida el atajo: la condición de §3 NO se cumple
Parecía cumplida —F·2·0 cerrada, USER_AUTHZ=enforce en producción desde el 08-12— y la
base dice que no:
securable_kind usados: workspace 389 grants · schema 24 grants
⛔ NINGUNO de tabla / dataset
los 12 usuarios humanos: 45 grants, TODOS de alcance `workspace`
TABLE_WRITE_DATA: 16 grants · ⛔ ningún HUMANO — sólo 2 servicios
(`maintenance` y `sql-editor`/`dml-writer`), sobre workspace
⭐ El esquema SÍ soporta el alcance por objeto (principal_grants tiene
securable_kind y securable_id), pero ningún dato lo ejerce. Una retícula capaz que
no se puebla sigue siendo, en la práctica, una retícula de workspace.
⭐⭐ Y quien escribe no es el usuario: es el servicio. El privilegio de escritura vive
en sql-editor/dml-writer, y el humano no lo tiene. La atribución existe
(carbon.operation-id + carbon.principal en el summary del snapshot, verificado el
08-14), pero atribuir no es autorizar.
⇒ Retirar hoy la lista de verbos no daría «permiso por usuario»: dejaría pasar cualquier
verbo bajo el privilegio ancho del servicio. La lista sigue sustituyendo a algo que aún
no existe. §3 sigue vigente y su desbloqueo es poblar el alcance de objeto, no encender
USER_AUTHZ —que ya está encendido—.
La tabla, entrada por entrada
DML_VERBS_PERMITIDOS = {DELETE, MERGE, UPDATE} (junction.ts:696)
| verbo | hoy | su condición de retirada | comprobada | veredicto |
|---|---|---|---|---|
SELECT | ✅ | — | 08-14 | lee por Gravitino, con dos namespaces en una sentencia |
UPDATE · DELETE · MERGE | ✅ admitidos | — | 08-14 | commit real verificado en el log del catálogo, firmado y con CAS intacto |
INSERT | ⛔ vetado | «su fuente (SELECT … FROM …) no se resuelve» | 12-08 · CADUCADA | ⛔⛔ el motivo se midió falso en frío hace dos días y el veto sigue puesto. Además es más ancho que su motivo: un INSERT … VALUES no tiene fuente que resolver y cae igual |
CREATE | sale por su propio camino (sin writeTarget) | — | 08-14 | ⛔ roto por el catálogo, no por Junction: el hook importTable de Gravitino (§handoff 08-14) |
ALTER de esquema | ✅ admitido y FUNCIONANDO | el contrato de esquema de Index ya existe | 08-14 | ✅ verificado de ida y vuelta: ADD COLUMN → Index reconcilia 6 → 7 columnas · +[w7_probe], DROP COLUMN → 7 → 6. Exige PLAN_MANDA=on, que en producción ya lo está |
DROP | ⛔ vetado | (sin condición escrita) | — | única entrada sin condición de retirada ⇒ no se puede saber cuándo tocaría revisarla |
⭐⭐ PLAN_MANDA: encendido en PROD, apagado en LOCAL — y eso engaña
Leído del runtime, no de la consola (GET /api/diag/motor con x-diag-key):
"interruptores": { "planManda": true, "userAuthz": "enforce" } · PLAN_MANDA_definida: true
⚠️ En .env.local NO está, así que cualquier medición local reproduce el deploy
equivocado salvo que se ponga a mano. Es la trampa de esta sesión: se dedujo «apagado en
producción» a partir del .env.local y del texto de un rechazo, y el runtime lo desmintió.
El diferencial, corrido con el flag en los dos estados (mismo SQL, misma sesión):
PLAN_MANDA=off | PLAN_MANDA=on (como PROD) | |
|---|---|---|
INSERT … VALUES | ⛔ «El DDL de esquema está admitido…» — prefijo falso | ⛔ «El SQL Editor no admite "INSERT"… Admitidos: DELETE · MERGE · UPDATE» — correcto |
INSERT … SELECT | ⛔ igual, prefijo falso | ⛔ igual, correcto |
ALTER … ADD COLUMN | ⛔ «PLAN_MANDA no está activo» | ✅ PASA · Index reconcilia 6 → 7 columnas |
🪤 De ahí sale el diagnóstico correcto del mensaje engañoso. El prefijo «El DDL de
esquema está admitido, pero no se pudo aplicar en este despliegue» se elige cuando
diagAlter.porque tiene valor — y con el flag encendido eso sólo ocurre si el motor
no pudo planificar la sentencia. Pero el prefijo es fijo y no incluye porque (sólo
la rama de ALTER lo interpola, junction.ts:2062), así que el motivo real se pierde:
el usuario lee «en este despliegue» y se va a mirar la configuración, cuando lo que falló
fue la planificación. Sigue incumpliendo lo que su propio comentario exige: «un mensaje
sólo es honesto si su primera línea ya es la verdad».
⛔ Una discrepancia ABIERTA, medida el 08-14
El editor de la UI devolvió el mensaje de PLAN_MANDA=off mientras /api/diag/motor
del mismo dominio reporta planManda: true. Las dos cosas no pueden ser ciertas a la
vez en el mismo deployment. Hipótesis a separar, sin dar ninguna por buena:
- la petición salió de otro deploy (preview / local) que el diag no representa;
- la variable se añadió hace 2 días y la función del editor no la ve — que es el patrón
ya documentado en
vercel-funciones-no-propagan; - el rechazo se produjo por una vía distinta de la que se está leyendo.
Control barato y decisivo: repetir un ALTER TABLE … ADD COLUMN desde el editor. Si
pasa, el flag manda allí y el mensaje anterior venía de otro sitio. Si vuelve a decir
«PLAN_MANDA no está activo», hay una discrepancia real entre el diag y el editor.
El orden que se deduce AHORA
① poblar el ALCANCE DE OBJETO en los grants ← la condición real de §3, y no está hecha
② cerrar la discrepancia del flag ← un ALTER desde el editor; ya no es «encender»
③ soltar INSERT … VALUES ← su motivo ya no aplica; el … SELECT espera a ①
④ dar condición de retirada + fecha a DROP ← la única entrada que no se puede auditar
⑤ arreglar el prefijo del mensaje ← barato, y hoy manda a depurar al sitio falso
⑥ poner PLAN_MANDA en `.env.local` ← o toda medición local seguirá mintiendo
⚠️ ① no es «más gobernanza»: es lo que convierte cada verbo nuevo en una concesión de permiso en vez de una decisión de plataforma. Mientras no esté, ③ y siguientes se abren para todo el mundo a la vez.
5 · Lo que SÍ está maduro — para no tirar el niño con el agua
| Idempotencia | Idempotency-Key con huella de SQL+destino, 409 ante conflicto de huella, respuesta cacheada. Es de lo mejor construido del repo |
| Ledger y atribución de snapshot | cada escritura deja asiento, con el snapshot observado (no inventado) |
| El destino viaja aparte del SQL | writeTarget autorizado fuera de banda: el motor no puede escribir otra tabla |
Guardián del DELETE con NULLs | detecta el corruptor de <>/!=/NOT (=) leyendo el plan normalizado |
| Reconciliación de Index tras DDL | fusiona en vez de sobrescribir; conserva lo que Index sabe y el motor no |
| El destino lo da el PLAN | desde P·3, sin lista de verbos de por medio |
6 · El orden que se deduce
① grants de USUARIO desbloquea la retícula … y con ella la lista de verbos
② reintento ante conflicto + elegir nivel de aislamiento a propósito
③ commit MULTI-TABLA la capacidad ya está en el catálogo, sin estrenar
④ transacción multi-sentencia y con ella, la Action atómica
⑤ relectura de la lista con condición de retirada y fecha por entrada
⚠️ ① no es una fase de gobernanza: es la que abarata todo lo demás. Mientras la autorización sea global, cada verbo seguirá siendo una decisión de plataforma.