Published

El sustrato de ESCRITURA — debilidades, medidas

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

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.md y 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 nuestraQué preguntaDe 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_PERMITIDOS existe 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)

verbohoysu condición de retiradacomprobadaveredicto
SELECT08-14lee por Gravitino, con dos namespaces en una sentencia
UPDATE · DELETE · MERGE✅ admitidos08-14commit 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
CREATEsale por su propio camino (sin writeTarget)08-14roto por el catálogo, no por Junction: el hook importTable de Gravitino (§handoff 08-14)
ALTER de esquemaadmitido y FUNCIONANDOel contrato de esquema de Index ya existe08-14✅ verificado de ida y vuelta: ADD COLUMN → Index reconcilia 6 → 7 columnas · +[w7_probe], DROP COLUMN7 → 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=offPLAN_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

IdempotenciaIdempotency-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 snapshotcada escritura deja asiento, con el snapshot observado (no inventado)
El destino viaja aparte del SQLwriteTarget autorizado fuera de banda: el motor no puede escribir otra tabla
Guardián del DELETE con NULLsdetecta el corruptor de <>/!=/NOT (=) leyendo el plan normalizado
Reconciliación de Index tras DDLfusiona en vez de sobrescribir; conserva lo que Index sabe y el motor no
El destino lo da el PLANdesde 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.


7 · Fuentes