Published

W3 · Cerrar el ledger — mini-spec

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

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.md donde 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ónPor qué está abierta¿Se puede cerrar?
11 · editor/DuckDBel snapshot no lleva marca — DuckDB no puede estamparlanunca por ratify. Sólo reconciliación explícita (W5)
5 · sync.full4 de 5 escriben en una tabla que ya no existeaborted legítimo
4 · s5-1-gate3 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 equivocadoratify.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 pasada8 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 snapshotel 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 verboun DELETE se asienta como type=SNAPSHOT
write_target en dialecto ajenoel ledger guarda lake."main.default"."ds_…" mientras escribe Spark
⚠️ rowsAffected siempre 0sparkEngine.runQuery no devuelve commit, y la puerta hace r.commit?.rows ?? 0
📏 La ventana, por el camino real3.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 haceQué nos dice
Delta LaketxnAppId + txnVersion: se comprueban ANTES de escribir contra el log de commits, y el escritor sólo tiene éxito si la versión supera la registradaEs pre-declarar — el patrón de D9, y viene de Databricks. Confirma la elección
Iceberg wap.idpublish_changes falla si hay varios snapshots con el mismo wap_id ⇒ detección de commit duplicadoExiste, 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

PreguntaLo 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 investigarDespués
B1cualificar el destino a la FQN física de Sparkno cualificarlo al físico: mandar <ns>.<nombre lógico> y que resuelva la cara
B2pendiente de una sonda destructiva para saber si el CTE se come el borradono hace falta: quitando el CTE del destino, la ambigüedad desaparece por construcción. La sonda pasa a ser confirmación, no requisito
D9decidido por razonamiento propioconfirmado por Delta (txnAppId) y UC (statement_id)
D10separado por prudenciaseparado 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íaCubre el caso felizCubre el caso que motiva ratify (el proceso muere tras commitear)
AObservar — la puerta lee el snapshot tras el commit y guarda el id que encuentreno: si muere en la ventana, la txn queda sin id y es inverificable para siempre
BPre-declarar — la puerta declara txn.id contra la cara ANTES de ejecutar; la cara firma con él: el id correcto está DENTRO del commit antes de que nadie pueda morirse
CVerificar por snapshotIdno, 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=true por tabla, y eso está medido como no activo: W2 probó SET spark.wap.id y 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).

FaseQué entregaGate
🏁 W3·0El censo — HECHOlas 6 mediciones de §2w3-0-censo-ledger.ts, salida 0 y 20 txn enumeradas por población
🏁 W3·b⭐⭐ El e2e que faltaba — HECHOel camino del producto, ejercido por primera vez desde W2w3-b-gate-puerta-escribe.tssalió EN ROJO, y el rojo fue el entregable: destapó B1 y B2 (§2·bis)
🏁 W3·a·0LA ESCRITURA, ARREGLADA — HECHOel destino se cualifica al dialecto del motor · el CTE deja de poder capturarloUPDATE por la puerta: snapshots 6 → 7 (+1) (antes ParseException) · type=UPDATE (antes SNAPSHOT) · write_target=main.default.\censo_poblacional“
🏁 W3·aLa verdad en el ledger (D8) — HECHOengine derivado · attributable derivado y sigue en false · snapshot_attribution: 'none' · el diagnóstico de unverifiable distingue sus tres causasgate W3·b VERDE, salida 0 · 300/300 · y el censo mide 2 × engine=spark en el ledger
🏁 W3·c⭐⭐⭐ El claim (D9) — HECHO Y DESPLEGADOla puerta pre-declara (attributable:true) · la cara busca ese mismo campo y firma con el txn.idw3-c-gate-atribucion.ts salida 0 contra producción: landed=true · el negativo discrimina · ratifycommitted. Ver §5·bis
🏁 W3·dIdempotencia del DML (D10) — HECHOse reusa sdk_idempotency_keys con un scope nuevo · la clave la pone el cliente y sobrevive al reintentow3-d-gate-idempotencia.ts salida 0, con cuatro negativos. Ver §D10·bis
🏁 W5⭐⭐ La cola del ledger — HECHO21 open0✅ 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·cEl censo de la deriva — HECHO, y no propone144 físicas · 109 datasets · 89 casan · 55 huérfanas · 20 fantasmasw5-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érfanas30 main.test · 1 main.defaultdel Warehouse de producto
12 datasets · 11 bench · 2 test.*namespaces LEGACY, otra pregunta
Fantasmas4 con status='error'animals, caretakers, customers, enclosure_zonesexactamente los datasets de 4 de las 5 txn sync.full que ratify abortó: dos mediciones independientes cuentan la misma historia
12 artefactosTransform path, canarios de gate
4 otrosprueba, 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:

  1. 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.
  2. ratify no corre solo: el scheduler no está cableado a ningún cron ni ruta (grep sobre vercel.json y app/), sólo se ejecuta a mano con --apply. Y su cortacircuitos para a los 3 abortos.
  3. Las txn que produjeron los gates quedaron committed, y ratify sólo procesa open.

⚠️ 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 DuckDBson 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 motorexige write.wap.enabled por tabla (D10)
La concurrencia de escriturados 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érfanasson de W5
El dialecto de escriturasigue sin medirse contra el corpus

7 · Riesgos, y cuál vigilar

  1. ⛔⛔⛔ EL VIVO, y es de producto, no de este plan: un DELETE del 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, y UPDATE/MERGE directamente lanzan. Es lo que convierte W3·a·0 en la primera fase.

  2. ⭐⭐ El de D8: attributable: true prematuro 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 escriba aborted. ⭐ Y ya tiene un caso concreto medido: un DML no-op cierra committed sin snapshot; con D8 aplicado tal cual, ratify lo declararía aborted. ⇒ D8 debe distinguir un tercer estado —«no había nada que commitear»— que hoy el enum snapshot_attribution colapsa en ambiguous.

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

  4. 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-authz sigue siendo quien autoriza. Emparejado con el riesgo ya abierto de BRIDGE_JWT_SECRET simétrico.

  5. ratify pasa a poder cerrar cosas de verdad, y su cortacircuitos está calibrado para una cola de ceroRATIFY_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

DocPara qué
sustrato-03.mdla foto completa. Manda sobre esto
w-write-path-approach.mdel camino de escritura W0-W5. Esta spec desarrolla su W3 y corrige §D7
w1-sort-order-repair.mdel precedente de cirugía medida