W3 · Cerrar el ledger — mini-spec
Fecha: 2026-08-10. Desarrolla la fase W3 de
w-write-path-approach.md, que la dejó planteada como
«idempotencia» y con dos vías sin probar y sin elegir.Existe porque al medirlo (
w3-0-censo-ledger.ts) apareció que el bloqueante no
es el que el approach nombra. W3 no falla por no poder propagar un identificador:
falla antes, porque el ledger declara del editor cosas que dejaron de ser
verdad el 2026-08-10.Regla, la de siempre: cada hecho lleva el comando que lo demuestra. Lo que no
lo lleva va marcado como decisión (§4) o riesgo (§7).Manda
sustrato-03.mddonde discrepen.
1 · Qué cierra W3, y por qué no es «propagar un id»
W2 dejó el dato firmado: cada commit del editor lleva carbon.operation-id y
carbon.principal dentro del mismo snapshot. Lo que W2 no dejó es que
alguien pueda volver a preguntar después qué pasó con una escritura concreta —
que es la única razón por la que ratify existe:
«El camino feliz nunca fue el problema: el problema es el hueco entre que el motor
commitea y el ledger lo apunta. Si el proceso muere ahí, hasta ahora no había forma
de saber qué había pasado.» —lib/compute/ratify.ts
⇒ W3 es el verbo ratify funcionando sobre las escrituras del editor. Y el
approach lo planteó como un problema de propagación (que el id que firma la cara
sea el txn.id de la puerta). Medido, la propagación es el tercer obstáculo de
tres, y los dos primeros no estaban escritos en ningún sitio.
2 · Lo MEDIDO — w3-0-censo-ledger.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/w3-0-censo-ledger.ts
2·1 · No son 10 transacciones colgadas: son 20, y en tres poblaciones distintas
【1】 TRANSACCIONES `open`: 20
11 × sql-editor.dml engine=duckdb attributable=false 9,1 – 12,1 d (TODAS sobre ds=38b26211)
5 × sync.full engine=— attributable=true 7,4 – 7,9 d
4 × s5-1-gate engine=spark attributable=true 22 h – 1,1 d
⭐ Tres poblaciones con tres causas distintas. Tratarlas como «10 colgadas» era lo que hacía parecer que había un solo problema:
| Población | Por qué está abierta | ¿Se puede cerrar? |
|---|---|---|
| 11 · editor/DuckDB | el snapshot no lleva marca — DuckDB no puede estamparla | ⛔ nunca por ratify. Sólo reconciliación explícita (W5) |
5 · sync.full | 4 de 5 escriben en una tabla que ya no existe | ✅ aborted legítimo |
4 · s5-1-gate | 3 no aterrizaron · 1 sí | ✅ el mecanismo funciona |
2·2 · ⭐ El mecanismo ENTERO funciona — hay una prueba positiva
【3】 4d986ab2 → ✅ CASA · snapshot=6913339168210360000
casan=1 · no_casan=19 · no_se_pudo=0
4d986ab2 es del gate de S5·1, que firmó con DataFrameWriterV2 usando el id de
la transacción. ratify la resuelve committed sin tocar nada.
⇒ La cadena txn.id → snapshot → veredicto está construida y probada. Lo que
falta no es el mecanismo: es que el camino del editor lo use.
2·3 · ⛔⛔ EL BLOQUEANTE REAL — ratify no llega ni a preguntar
withDmlControlPlane estampa el metadata del ledger con literales
(junction.ts:604):
const ledgerMetadata: Record<string, unknown> = {
engine: 'duckdb', // ⛔ desde W2 el motor es SPARK
attributable: false, // ⛔ desde W2 LA CARA FIRMA el commit
};
Las dos eran ciertas cuando se escribieron y dejaron de serlo el 08-10. Y la
segunda es la que bloquea, porque ratify la lee como una declaración del escritor:
if (declared !== true || !isAttributable(mode)) return { verdict: 'unverifiable', … }
⇒ ratify sale unverifiable ANTES de preguntarle al catálogo. Con la firma
perfecta y el id perfectamente propagado, el veredicto seguiría siendo el mismo.
【2】 resumen: {"unverifiable":11,"aborted":8,"committed":1}
⭐ Y no es un descuido aislado: el mismo bloque devuelve engine: 'duckdb' al editor
(junction.ts:1753), así que la superficie también miente sobre quién sirvió la
escritura — el fallo que §12·9 del sustrato describe.
2·4 · ⛔⛔ NINGUNA escritura del editor ha pasado por la puerta desde W2
【5】 escrituras del editor entre las últimas 200 cerradas: 17
17 × engine=duckdb attributable=false
la más reciente: e7b30137 · 2026-08-01T14:13:34Z
El 01-08. Nueve días antes de W2. Y la razón está en el propio gate de W2
(w2-gate-escritura-firmada.ts): mide puente → Spark → cara y se salta la
puerta entera — no pasa por runQuery, así que no abre transacción de ledger.
⇒ W2 probó que la cara firma. No probó que el camino del producto escriba. Es exactamente §12·5 del sustrato: un health-check dice que el proceso vive; sólo un e2e que ESCRIBA dice que el camino de escritura existe — y aquí el e2e existía pero medía un tramo más corto que el real (§12·2).
2·5 · La ventana, medida — cota superior
【4】 17 × sql-editor.dml p50=2.058 ms p95=3.357 ms max=3.357 ms
11 × sync.full p50=14.138 ms p95=17.248 ms max=17.248 ms
169 × (sin writer) p50=510 ms p95=13.290 ms max=18.769 ms
Es created_at → committed_at, o sea incluye la ejecución del DML: el hueco real
entre el commit de Iceberg y el asiento del ledger es menor que esto. Una cota
superior basta para decidir — si el techo es aceptable, el hueco lo es más.
⇒ La ventana del editor es de ~2-3 s. Es el número que el approach pedía medir antes de elegir vía (§D7).
2·6 · Dos defectos menores que el censo destapó
El diagnóstico de unverifiable apunta al sitio equivocado | ratify.ts:118 sólo distingue declared === undefined. Con declared === false emite el texto de la rama de upsert — «pyiceberg no acepta snapshot_properties en upsert»— sobre transacciones cuyo write_mode es replace. Un operador que lea eso irá a mirar pyiceberg, y el problema está en la puerta. §12·4 |
| El cortacircuitos pararía la pasada | 8 saldrían aborted y RATIFY_MAX_ABORTS_PER_CYCLE = 3. Es el diseño funcionando, pero significa que W5 no se cierra con una pasada |
2·bis · Lo MEDIDO al EJERCER el camino — w3-b-gate-puerta-escribe.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/w3-b-gate-puerta-escribe.ts
La primera ejecución del camino del producto desde W2. Y el resultado cambia el orden de las fases: antes de cerrar el ledger hay que arreglar la escritura, que está rota en dos de los tres verbos que el editor admite.
⛔⛔ B1 · UPDATE y MERGE por la puerta lanzan ParseException
UPDATE lake."main.default"."ds_5305948542f04a7dbaa7de14288d8f79" SET `censo_id` = `censo_id`
^^^^^ [PARSE_SYNTAX_ERROR] Syntax error at or near '"main.default"'
La causa, en una línea. B6 hizo el plan de lectura sensible al dialecto:
const planResult = buildDuckReadPlan(sql, catalog, { // junction.ts:1421
dialect: motor.engine === 'spark' ? 'spark' : 'duckdb',
});
…y la cualificación del DESTINO se quedó atrás, con el alias fijo:
const fqn = datasetFqn('lake', entry.iceberg_namespace ?? null, entry.id); // junction.ts:1494
// «Mismo alias de catálogo que el plan de lectura ('lake')» ← ya NO es cierto
⭐ El comentario dice por qué era correcto, y ese porqué caducó. lake es el
catálogo adjunto de duck-server; el de Spark es otro.
⚠️ Y DELETE se salva por accidente, no por diseño: su destino va tras FROM, así
que lo cualifica el plan de lectura y qualifyWriteTarget ni llega a actuar. ⇒ Los
tres verbos permitidos (DELETE · MERGE · UPDATE) se reparten en uno que funciona
por casualidad y dos rotos.
⛔⛔⛔ B2 · Y el que «funciona» apunta a un CTE — medido con control negativo
Para Spark, el plan no sustituye el nombre: emite un CTE homónimo
(duck-plan.ts:301), que es lo correcto para leer. Para un DML no lo es:
DELETE FROM zzz_no_existe_en_catalogo WHERE censo_id = -999999
sin CTE → FAILED · [TABLE_OR_VIEW_NOT_FOUND] ← el control negativo funciona
con CTE → SUCCEEDED ← ⛔ el CTE resuelve el destino
WITH zzz … UPDATE zzz SET … → FAILED · [UNSUPPORTED_FEATURE.TABLE_OPERATION]
UPDATE main.default.censo_poblacional SET … (sin CTE) → SUCCEEDED
⇒ zzz_no_existe_en_catalogo no es ninguna tabla, y con el CTE delante el DELETE
no falla. El CTE participa en la resolución del destino.
⭐ De los tres comportamientos, dos son ruidosos y uno silencioso, y el silencioso
es el peligroso: UPDATE con CTE lanza; DELETE con CTE devuelve éxito. La
hipótesis abierta es que un DELETE del editor pueda borrar de la copia efímera y
reportar éxito.
⚠️ Y no se cierra aquí a propósito. Distinguir «borró de la tabla» de «borró del
CTE» exige un DELETE que CASE FILAS, o sea escribir destructivamente sobre datos
reales — y no hay tabla desechable porque CREATE TABLE es W4. Queda como la
incógnita abierta de W3·a, y es la primera que hay que cerrar.
Lo demás que midió el gate
| ✅ La puerta ELIGE Spark | [junction] motor=spark op=write · spark.available=true url=true secret=true |
⛔ Y declara engine="duckdb" | en el resultado y en el ledger. D8 confirmado por el camino real, no por lectura de código |
⛔ Una txn committed SIN snapshot | el no-op cierra en committed con snapshot_attribution: 'ambiguous'. ⭐ Con D8 aplicado, ratify la declararía aborted — el riesgo nº1 de §7, ahora con un caso concreto y reproducible |
| ⛔ El ledger clasifica mal el verbo | un DELETE se asienta como type=SNAPSHOT |
⛔ write_target en dialecto ajeno | el ledger guarda lake."main.default"."ds_…" mientras escribe Spark |
⚠️ rowsAffected siempre 0 | sparkEngine.runQuery no devuelve commit, y la puerta hace r.commit?.rows ?? 0 |
| 📏 La ventana, por el camino real | 3.888 ms (no-op) — coherente con la cota superior de §2·5 |
⇒ Y lo que esto dice del método
Las tres primeras lecturas del gate fueron rojos FALSOS —«no hay snapshot», «el
ledger no crece», «hay nombres duplicados»— y las tres eran de la sonda: un
DELETE … WHERE 1 = 0 no publica snapshot porque no debe, y el nombre pelado
censo_poblacional existe en dos schemas. §12·13 otra vez, y van cuatro.
⭐ Lo que las convirtió en hallazgos fue no creerle al primer rojo: leer el estado del catálogo y del ledger en vez del código de salida (§12·14), y añadir el control negativo que separa «el sistema está roto» de «la sonda mide otra cosa».
3 · El ESTADO DEL ARTE — buscado, no supuesto (2026-08-10)
Se investigó antes de escribir la corrección, y el resultado la cambió: lo que parecía un arreglo (cualificar bien el destino) resultó ser una retirada (dejar de cualificarlo).
3·1 · ⭐⭐⭐ Resolución de nombres — el catálogo resuelve, el motor NO reescribe
«Clients reference tables by their logical names through the REST catalog,
which then resolves the actual storage locations transparently.»
Ése es el contrato del Iceberg REST catalog, y es el mismo que Unity Catalog: el
identificador (namespace.tabla) es independiente de la ubicación física. Nadie
en el sector reescribe el SQL del usuario para mapear lógico → físico; el catálogo
lo hace en el loadTable.
⭐ Y nosotros ya lo hacemos desde el 08-10 (lib/governance/catalog-names.ts).
⇒ La reescritura del plan es un residuo de la era anterior, y está MEDIDO que ya
no hace falta:
UPDATE main.default.censo_poblacional SET censo_id = censo_id WHERE … → SUCCEEDED
DELETE FROM main.default.censo_poblacional WHERE … → SUCCEEDED
El nombre lógico funciona contra Spark tal cual, sin CTE y sin sustitución.
⇒ La corrección de B1 no es cualificar el destino al dialecto de Spark: es dejar de cualificarlo al físico. Una sola traducción —añadir la coordenada— y la del nombre la hace quien debe. Es exactamente el argumento con el que se descartó slugificar el 08-10: no introducir una SEGUNDA traducción.
3·2 · ⭐⭐ Un CTE homónimo del destino es un defecto NUESTRO, no de Spark
En SQL estándar el CTE entra en el ámbito de visibilidad de nombres, y no es una rareza: MySQL registró como bug el comportamiento contrario —«single-table DELETE ignores CTEs, violating SQL's name visibility rules» (Bug#30796015)—. Y la regla complementaria es explícita: no se puede borrar de un CTE; hay que hacer el DELETE sobre la tabla subyacente.
⇒ Spark hace lo correcto. Lo incorrecto es que nuestro plan meta un CTE con el mismo nombre que el destino de un DML. La corrección de B2 y la de B1 son la misma: el destino de escritura no pasa por el mecanismo de CTE.
3·3 · Idempotencia — Delta y Iceberg, y por qué elegimos como elegimos
| Cómo lo hace | Qué nos dice | |
|---|---|---|
| ⭐ Delta Lake | txnAppId + txnVersion: se comprueban ANTES de escribir contra el log de commits, y el escritor sólo tiene éxito si la versión supera la registrada | Es pre-declarar — el patrón de D9, y viene de Databricks. Confirma la elección |
Iceberg wap.id | publish_changes falla si hay varios snapshots con el mismo wap_id ⇒ detección de commit duplicado | Existe, pero exige write.wap.enabled=true = modo stage: lo escrito no se ve hasta publicar. Inaceptable para un editor interactivo ⇒ confirma D10 (separarlo) |
3·4 · ⭐⭐ Atribución — Unity Catalog usa un ID DE SENTENCIA, asignado antes
statement_id enlaza query_history con table_lineage/column_lineage (vía
entity_run_id), y sirve para «identificar qué query produjo qué versión de la
tabla». Los eventos de escritura pura se reconocen porque el destino no es nulo y
el origen sí.
⭐ Es literalmente el modelo de D9: un identificador de la SENTENCIA, propiedad del plano de control, asignado antes de ejecutar, que enlaza la operación con la versión de la tabla. No lo genera el motor y no se observa después. ⇒ La vía A (observar el snapshot y deducir) no tiene análogo en el sector.
3·5 · Y las dos que ya estaban
| Pregunta | Lo que hace el sector |
|---|---|
| ¿Quién es dueño del identificador? | El plano de control, no el motor. La trazabilidad vive en el log del catálogo, no en la credencial ni en el ejecutor |
| ¿Y si el identificador no puede viajar por el motor? | Se declara fuera de banda, contra el catálogo, y éste lo aplica al commit que le llega. El mismo movimiento del 08-10 (nombres lógicos y firma): lo que el motor no puede hacer, lo hace el catálogo (§12·17) |
⇒ Lo que la investigación cambió
| Antes de investigar | Después | |
|---|---|---|
| B1 | cualificar el destino a la FQN física de Spark | ⭐ no cualificarlo al físico: mandar <ns>.<nombre lógico> y que resuelva la cara |
| B2 | pendiente de una sonda destructiva para saber si el CTE se come el borrado | ⭐ no hace falta: quitando el CTE del destino, la ambigüedad desaparece por construcción. La sonda pasa a ser confirmación, no requisito |
| D9 | decidido por razonamiento propio | confirmado por Delta (txnAppId) y UC (statement_id) |
| D10 | separado por prudencia | separado por dato: wap.enabled es modo stage |
4 · Las decisiones
⭐⭐ D8 · Primero la VERDAD en el ledger — y es independiente de todo lo demás
withDmlControlPlane deja de declarar literales y declara lo que observa: el
motor que eligió la puerta, y attributable derivado de si ese camino firma.
No es limpieza cosmética y por eso va primera: mientras attributable sea false,
ninguna de las vías de §D9 cambia un solo veredicto. Y su recíproco es la trampa —
poner attributable: true sin que el id case convertiría 11 unverifiable (honestos)
en 11 aborted (falsos), que es el peor fallo posible del protocolo y el que
ratify.ts dice explícitamente evitar.
⇒ D8 y D9 se despliegan juntos o no se despliegan. El orden es de construcción, no de entrega.
⭐⭐⭐ D9 · El id se PRE-DECLARA contra la cara — el claim ES el WAP.id de D3
Las tres vías, con lo que cada una cuesta y lo que cada una no cubre:
| Vía | Cubre el caso feliz | Cubre el caso que motiva ratify (el proceso muere tras commitear) | |
|---|---|---|---|
| A | Observar — la puerta lee el snapshot tras el commit y guarda el id que encuentre | ✅ | ⛔ no: si muere en la ventana, la txn queda sin id y es inverificable para siempre |
| B | ⭐ Pre-declarar — la puerta declara txn.id contra la cara ANTES de ejecutar; la cara firma con él | ✅ | ✅ sí: el id correcto está DENTRO del commit antes de que nadie pueda morirse |
| C | Verificar por snapshotId | ✅ | ⛔ no, y además rompe S5·1: el snapshot se observa DESPUÉS |
⇒ Se elige B, y el criterio es uno solo: A y C cubren el camino feliz, que nunca fue el problema. Adoptarlas sería construir un reconciliador que funciona exactamente cuando no hace falta.
⭐ Y B cierra el círculo con D3 del approach, que pedía reusar
carbon.operation-id como WAP.id sin decir dónde ponerlo. El claim es esa
marca: se declara antes de ejecutar, sirve para atribuir y para deduplicar, y
va en la cara por la misma razón que la firma — el motor no puede.
Cómo funciona, en cuatro pasos:
puerta: abre la txn del ledger → txnId
puerta: POST /api/iceberg/…/operations {txnId, datasetId} (el CLAIM, con TTL)
motor: INSERT … → updateTable → la cara
cara: ¿hay claim vigente para (esta tabla, este principal)?
uno → firma con ÉL y lo consume
cero → firma con uuid propio (el respaldo de W2, intacto)
varios→ firma con uuid propio y lo marca AMBIGUO
⚠️ La rama de «varios» no elige, y es deliberado. Dos escrituras concurrentes del mismo principal sobre la misma tabla no se pueden desambiguar desde la cara. Firmar con uno de los dos claims produciría una atribución falsa, que es peor que una anónima: una firma equivocada se lee como prueba. ⇒ Se firma anónimo y se dice.
⚠️ El claim NO autoriza. Igual que la resolución de nombres lógicos (§12·17), sólo
identifica. Quién puede escribir lo sigue decidiendo catalog-authz, después y sobre
el nombre ya físico. Un permiso decidido en dos sitios acaba divergiendo.
D10 · La DEDUPLICACIÓN de reintento se separa de W3, y se dice por qué
El approach mete «idempotencia» y «cerrar el ledger» en la misma fase. Son dos cosas y la segunda no depende de la primera:
- Cerrar el ledger (W3) necesita que el id case ⇒ D8 + D9. Es lo que se entrega.
- Deduplicar un reintento de verdad —que Iceberg rechace el segundo commit—
necesita
write.wap.enabled=truepor tabla, y eso está medido como no activo: W2 probóSET spark.wap.idy el summary salió limpio precisamente por eso. Activarlo son 144 tablas y una decisión de sustrato, no de esta fase.
⭐ Lo que sí se puede hacer sin WAP, y es la mitad del valor: con el claim, la
puerta puede preguntar getOperationStatus(dataset, txnId) antes de re-ejecutar
un reintento de la misma transacción; si ya aterrizó, no se re-ejecuta. Dedup en la
puerta, no en el motor. Entra en W3·d.
🏁 D10·bis · W3·d, HECHO — y no fue lo que decía esta spec
La idea de arriba no funciona, y el motivo es de una línea: withDmlControlPlane
abre una transacción NUEVA en cada llamada, así que un reintento nunca comparte
txn.id y no habría nada que deduplicar. La idempotencia necesita una clave que
sobreviva al reintento, y ésa sólo la puede poner el cliente.
⭐⭐ Y el mecanismo ya existía en el repo: lib/sdk/idempotency.ts, con el contrato
de Stripe completo —hit devuelve lo guardado sin ejecutar, conflict da 409 si la
misma clave llega con otro cuerpo, TTL 24 h, comprobación de tenencia— y su tabla
sdk_idempotency_keys. ⇒ W3·d no fue construir nada: fue enchufar el editor. Montar
un segundo mecanismo habría sido el error que este repo tiene anotado: dos sitios donde
decidir lo mismo, y el día que discrepen gana el equivocado.
El caso que cubre, concreto: el adaptador de Spark corta a los 45 s
(wait_timeout_s) y devuelve «la query no terminó dentro del tiempo de espera» — y
la sentencia puede haber terminado después. Si el usuario reintenta, escribe dos
veces. Un DELETE repetido suele ser inocuo; un MERGE o un UPDATE acumulativo, no.
⚠️⚠️ Y el error que casi se cuela, cometido y corregido en el sitio: la primera
versión del cliente acuñaba crypto.randomUUID() en cada ejecución. Eso no
deduplica nada — dos intentos con dos claves son dos operaciones distintas para el
servidor. La clave se acuña por SQL y se conserva mientras el intento no haya
triunfado; se suelta al terminar con éxito, porque volver a pulsar «ejecutar» es una
operación nueva que el usuario pide a sabiendas.
⚠️ Sólo se cachea el ÉXITO. Cachear un fallo convertiría un error transitorio —el puente caído, un timeout— en permanente para esa clave: el usuario reintentaría y recibiría el mismo error para siempre, sin haber llegado a escribir nunca.
① la clave se valida ✅
② primera vez → miss el handler DEBE ejecutarse
③ mismo SQL, misma clave → hit devuelve lo guardado · NO re-escribe
④ otra sentencia, misma clave → conflict
⑤ otra clave → miss la caché no responde a todo
⑥ otro workspace → miss la tenencia se respeta
🏁 salida 0
⚠️ Lo que este gate NO puede medir, dicho en vez de fingir cobertura: la ruta HTTP
de extremo a extremo. /api/sql-editor/execute va detrás de Clerk y un script sin
sesión recibe 404 —no 401—, que es §12·10 y ya costó tres deploys una vez.
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).
| Fase | Qué entrega | Gate | |
|---|---|---|---|
| 🏁 W3·0 | El censo — HECHO | las 6 mediciones de §2 | w3-0-censo-ledger.ts, salida 0 y 20 txn enumeradas por población |
| 🏁 W3·b | ⭐⭐ El e2e que faltaba — HECHO | el camino del producto, ejercido por primera vez desde W2 | w3-b-gate-puerta-escribe.ts — salió EN ROJO, y el rojo fue el entregable: destapó B1 y B2 (§2·bis) |
| 🏁 W3·a·0 | ⛔ LA ESCRITURA, ARREGLADA — HECHO | el destino se cualifica al dialecto del motor · el CTE deja de poder capturarlo | ✅ UPDATE por la puerta: snapshots 6 → 7 (+1) (antes ParseException) · type=UPDATE (antes SNAPSHOT) · write_target=main.default.\censo_poblacional“ |
| 🏁 W3·a | ⭐ La verdad en el ledger (D8) — HECHO | engine derivado · attributable derivado y sigue en false · snapshot_attribution: 'none' · el diagnóstico de unverifiable distingue sus tres causas | ✅ gate W3·b VERDE, salida 0 · 300/300 · y el censo mide 2 × engine=spark en el ledger |
| 🏁 W3·c | ⭐⭐⭐ El claim (D9) — HECHO Y DESPLEGADO | la puerta pre-declara (attributable:true) · la cara busca ese mismo campo y firma con el txn.id | ✅ w3-c-gate-atribucion.ts salida 0 contra producción: landed=true · el negativo discrimina · ratify → committed. Ver §5·bis |
| 🏁 W3·d | Idempotencia del DML (D10) — HECHO | se reusa sdk_idempotency_keys con un scope nuevo · la clave la pone el cliente y sobrevive al reintento | ✅ w3-d-gate-idempotencia.ts salida 0, con cuatro negativos. Ver §D10·bis |
| 🏁 W5 | ⭐⭐ La cola del ledger — HECHO | 21 open → 0 | ✅ 9 por ratify --apply (3 pasadas: 8 aborted + 1 committed) · 12 por reconciliación FORENSE con la prueba escrita en el asiento. Ver §5·quater |
| 🏁 W5·c | El censo de la deriva — HECHO, y no propone | 144 físicas · 109 datasets · 89 casan · 55 huérfanas · 20 fantasmas | ✅ w5-c-censo-deriva.ts. ⚠️ Las «32» del sustrato eran un subconteo: se midió por la cara, que filtra al inquilino |
⛔ El orden, y qué tapa a qué
W3·a·0 antes que todo → 2 de 3 verbos LANZABAN y el tercero podía no escribir.
Cerrar el ledger de escrituras que no ocurren no es cerrar nada.
W3·a sin W3·c → attributable=true con id que no casa → 11 `aborted` FALSOS
W3·c sin W3·a → el id casa y `ratify` ni pregunta → cero cambios
⭐ W3·b fue delante de todo, y por eso existe W3·a·0. No cambió una línea de producción: sólo ejerció el camino. Sin ese paso, W3·a y W3·c se habrían construido sobre una escritura rota — y sus gates habrían salido verdes midiendo un DML que lanzaba antes de llegar al catálogo.
5·quater · 🏁 W5 · LA COLA A CERO, y cómo se cerró lo incerrable
21 open → 9 por `ratify --apply` (3 pasadas: 8 aborted + 1 committed)
→ 12 por reconciliación FORENSE
= 0
⭐⭐ Las 12 del editor por DuckDB eran inverificables por construcción —su commit no
lleva carbon.operation-id, así que ratify no puede confirmarlas ni negarlas— y
habrían quedado open para siempre. open no es un estado falso (significa «no se
sabe»), pero un «no se sabe» permanente contamina una cola cuyo valor entero depende
de que cada entrada sea accionable.
La salida fue preguntar otra cosa. No se puede demostrar «esta operación no aterrizó»; sí se puede demostrar, cuando se cumple, algo más fuerte:
animal_events 11 txn del 07-29 al 08-01 · tabla con 12 snapshots · 0 en la ventana
censo_poblacional 1 txn del 08-10 16:16 · tabla con 11 snapshots · 0 en la ventana
Cero snapshots en toda la ventana ⇒ ninguna aterrizó. No es una prueba por operación: es una prueba sobre el conjunto, y basta.
⚠️ Y toca la regla de ratify —«es el ÚNICO sitio autorizado a escribir aborted,
porque es el único que puede DEMOSTRARLO»—, así que se hizo respetándola: ratify no
cambia (por su vía siguen saliendo unverifiable), es un script manual, recalcula
la evidencia en el momento, se retira ante cualquier resultado no concluyente, y
escribe la prueba en el propio asiento (metadata.reconciliation: ventana,
snapshots dentro, total de la tabla y motivo). Un aborted sin su prueba al lado es
lo que la regla prohíbe; con la prueba, es reconciliación.
⭐ La ventana es la misma constante que acota el claim de W3·c (VENTANA_EN_VUELO_MS).
La primera versión usó +1 h y salió AMBIGUA sobre una tabla en la que se había estado
escribiendo toda la tarde. Una ventana mal elegida no da un veredicto malo: da un
«no sé» que parece un «pudo pasar».
5·quinquies · El censo de la deriva (W5·c) — y el subconteo que corrige
144 físicas · 109 datasets · 89 casan · 55 HUÉRFANAS · 20 FANTASMAS
⚠️ El sustrato decía 32 huérfanas. Son 55. La medición anterior fue por la cara,
que filtra los listados a lo del inquilino —a propósito— así que sólo vio main.*
(30 en main.test + 1 en main.default ≈ las 32). Este censo va al catálogo directo.
⚠️⚠️ Y la primera versión de este censo contó 23 de 144, porque listNamespaces sin
?parent= devuelve sólo el primer nivel y las tablas viven en main.default /
main.test. Declaró FANTASMA a censo_poblacional, una tabla con 11 snapshots en la
que se acababa de escribir. §12·13, y van cinco en dos días.
| Grupo | ||
|---|---|---|
| Huérfanas | 30 main.test · 1 main.default | del Warehouse de producto |
12 datasets · 11 bench · 2 test.* | namespaces LEGACY, otra pregunta | |
| Fantasmas | 4 con status='error' | ⭐ animals, caretakers, customers, enclosure_zones — exactamente los datasets de 4 de las 5 txn sync.full que ratify abortó: dos mediciones independientes cuentan la misma historia |
| 12 artefactos | Transform path, canarios de gate | |
| 4 otros | prueba, b, s, gold_order_risk_episodes |
⇒ Qué hacer con cada grupo —adoptar, borrar o dejar— es decisión del owner. El approach lo dijo antes de empezar y el censo lo respeta: cuenta, no propone.
5·ter · 🏁 W3·c, CERRADO CONTRA PRODUCCIÓN (2026-08-10)
Desplegado (dpl_DSS6USeAPYrGYAHJp6hF9KtU3Wno, aliaseado a app.paladio.io) y medido:
③ landed=true snapshot=5897031087780282000 el catálogo reconoce el `txn.id`
④ uuid inventado → landed=false el negativo discrimina
⑤ ratify → **committed** la escritura es RECONCILIABLE
🏁 GATE W3·c VERDE · salida 0
Y w3-b-gate-puerta-escribe.ts sigue en verde, con su diagnóstico ya en ✅. El ledger
lo confirma por el otro extremo: 4 × engine=spark attributable=true.
⚠️ Qué mide exactamente, dicho antes de que alguien lo suponga: la cara es la de producción; la puerta corre en local, con el mismo commit que se acaba de desplegar. Atravesar la puerta desplegada exigiría pasar por Clerk (§12·10). Es el mismo código, pero no es el mismo proceso.
5·bis · ⚠️ Lo que se midió ANTES de desplegar, y por qué se conserva
El gate corre runQuery en local, pero el commit lo manda Spark, y Spark habla con
la cara DESPLEGADA — comprobado en el deployment del puente:
kubectl -n spark get deploy spark-bridge -o jsonpath='{…CATALOG_URI…}'
# https://app.paladio.io/api/iceberg
La búsqueda de la operación en vuelo vive en esa cara. ⇒ Contra la cara vieja, el gate sale en rojo, y sale bien:
① txn=2dffe082… el DML se ejecuta
② engine=spark attributable=true la puerta DECLARA (mitad nueva, ya en la rama)
③ landed=false la cara vieja firmó con su uuid ⛔
④ uuid inventado → landed=false el negativo discrimina ✅
⑤ ratify → **aborted** sobre una escritura que SÍ ocurrió (8 → 9 snapshots)
⭐⭐ El ⑤ es el aviso de la spec hecho carne. Con attributable:true declarado y la
firma sin casar, ratify pasa la guarda, pregunta, no encuentra el id y declara
aborted una escritura correcta. Es el riesgo nº1, reproducido en vivo.
Por qué NO es un peligro en producción, y qué lo sujeta:
- Las dos mitades viven en el mismo repo y Vercel las despliega juntas — la puerta
(
junction.ts) y la cara (app/api/iceberg/…) no pueden quedar desparejadas por un despliegue parcial. El desfase medido es de entorno local, no de producción. - ⭐
ratifyno corre solo: el scheduler no está cableado a ningún cron ni ruta (grepsobrevercel.jsonyapp/), sólo se ejecuta a mano con--apply. Y su cortacircuitos para a los 3 abortos. - Las txn que produjeron los gates quedaron
committed, yratifysólo procesaopen.
⚠️ Lo que sí hay que respetar: no revertir la cara dejando la puerta, ni correr
ratify-sweep --apply entre medias. Es la regla de D8 —se despliegan juntas o no se
despliegan— y ahora tiene una demostración en vez de un argumento.
⇒ Dónde queda W3 tras a·0 y a
El camino de escritura funciona y el ledger dice la verdad. Lo que queda es que el
ledger se pueda CERRAR, y eso es exactamente una cosa: el identificador del ledger
todavía no viaja dentro del commit. Hoy ratify lo dice con esas palabras.
⭐ Y attributable: false es ahora una afirmación derivada (esVerificable()), no
un literal olvidado. W3·c la invierte para el camino que lleve el id — y hay un test
que se invertirá con ella, para que sea un cambio deliberado y no un efecto colateral.
6 · Lo que W3 NO resuelve
| Las 11 txn de DuckDB | son inverificables por construcción — su snapshot no lleva marca. Cerrarlas es una decisión de reconciliación (W5), y el veredicto honesto no es committed ni aborted |
| La dedup real en el motor | exige write.wap.enabled por tabla (D10) |
| La concurrencia de escritura | dos escrituras a la vez sobre la misma tabla dan claim ambiguo y conflicto de CAS. W3 hace la primera parte visible; la segunda sigue sin reintento con backoff |
| Las 32 tablas huérfanas | son de W5 |
| El dialecto de escritura | sigue sin medirse contra el corpus |
7 · Riesgos, y cuál vigilar
-
⛔⛔⛔ EL VIVO, y es de producto, no de este plan: un
DELETEdel editor puede no borrar y devolver éxito (B2 de §2·bis). Está medido que el CTE participa en la resolución del destino; falta medir si además impide el borrado. Mientras no se cierre, el DML del SQL Editor por Spark no es de fiar, yUPDATE/MERGEdirectamente lanzan. Es lo que convierte W3·a·0 en la primera fase. -
⭐⭐ El de D8:
attributable: trueprematuro convierte inverificables honestos en abortos falsos. Es el único riesgo de este documento que destruye información en vez de dejarla como está. Mitigación: D8 y D9 se despliegan juntos, y W3·c tiene un gate positivo (landed === true) antes de que nada escribaaborted. ⭐ Y ya tiene un caso concreto medido: un DML no-op cierracommittedsin snapshot; con D8 aplicado tal cual,ratifylo declararíaaborted. ⇒ D8 debe distinguir un tercer estado —«no había nada que commitear»— que hoy el enumsnapshot_attributioncolapsa enambiguous. -
El claim añade una escritura a PG en el camino caliente del DML. Se acepta: la cara ya consulta PG para autorizar, resolver nombres y anotar el acceso. Pero si el claim falla, el DML NO se bloquea — se firma con uuid propio. Un fallo de atribución no puede costar una escritura.
-
Un claim vivo es una ventana de suplantación de ATRIBUCIÓN (no de acceso): quien pudiera escribir esa tabla con ese principal recogería un id ajeno. El TTL corto lo acota y
catalog-authzsigue siendo quien autoriza. Emparejado con el riesgo ya abierto deBRIDGE_JWT_SECRETsimétrico. -
ratifypasa a poder cerrar cosas de verdad, y su cortacircuitos está calibrado para una cola de cero (§RATIFY_MAX_ABORTS_PER_CYCLE). Con 8 abortos legítimos pendientes, la primera pasada real parará a los 3 — y eso hay que leerlo como el diseño funcionando, no como un fallo.
8 · Documentos
| Doc | Para qué |
|---|---|
⭐ sustrato-03.md | la foto completa. Manda sobre esto |
⭐ w-write-path-approach.md | el camino de escritura W0-W5. Esta spec desarrolla su W3 y corrige §D7 |
w1-sort-order-repair.md | el precedente de cirugía medida |