Published

📒 PIEZA · CATALOG-STORE — quién guarda la verdad de una tabla

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

📒 PIEZA · CATALOG-STORE — quién guarda la verdad de una tabla

Versiónv1.0las cinco capas
Estado🏁 Trazada y medida · ⛔ la ventana de recuperación está vacía
Última medición2026-08-12

Se traza por capas: 1 el sustrato · 2 el commit · 3 el ciclo de vida · 4 quién
entra · 5 el contrato
. Cada capa se mide antes de escribirse.


Qué es

Un proceso Rust con una base de datos Postgres propia, que guarda la verdad
de cada tabla Iceberg y serializa los cambios sobre ella.

Es la pieza que hace que el lakehouse sea un lakehouse: sin ella, R2 es una carpeta con Parquet dentro.

Qué NO es

  • No es la cara. ICEBERG-FACE es nuestro endpoint REST que autoriza y reenvía; esto es a quién reenvía. La cara pregunta «¿puede este motor?»; el catálogo responde «ésta es la tabla».
  • No es Index. Index gobierna; el catálogo registra. El invariante que los separa es literal y físico: son dos Postgres distintos, en dos proveedores distintos — Index vive en Supabase, esto en Railway. Index nunca serializa escrituras.
  • No es el storage. DATA-STORAGE guarda los bytes. Este guarda qué bytes cuentan, y es donde ocurre lo que el storage no puede hacer: la atomicidad.
  • No es nuestro código. Es Lakekeeper, Apache-2.0, desplegado tal cual. Lo nuestro son las decisiones de configuración — y ésas sí son de esta pieza.

Capa 1 · El sustrato

El proceso

curl -s https://heroic-victory-production-41dd.up.railway.app/health        # 200, sin token
curl -sH "Authorization: Bearer $TOK" …/management/v1/info
Lakekeeper0.13.0 · licencia Apache-2.0 · bootstrapped: true
DóndeRailway, proyecto cozy-comfortel mismo donde viven el writer, Duck, Keycloak y OpenFGA
Identidad del servidorserver-id 019f17ea-… · un solo proyecto, el Default Project (0000…)
Colas declaradastabular_expiration · tabular_purge · task_log_cleanup
Backend de autorizaciónopenfga — no allow-all. /health lo reporta sano

⚠️ Ese authz-backend: openfga merece un aviso ahora y una capa entera después (capa 4): es el interruptor de Lakekeeper, no el de Carbon. Que estén los dos y se llamen casi igual es exactamente cómo se lee mal el estado de un sistema.

Su base de datos

npx @railway/cli variables -s lakekeeper --kv | grep PG_DATABASE_URL   # host, sin credenciales
MotorPostgreSQL 18.4, servicio Postgres de Railway, volumen propio
Baserailway · 16 MB · 44 tablas, todas en public
PoolsPG_DATABASE_URL_READ y _WRITEal mismo servidor: son dos pools, no hay réplica de lectura
SecretosPG_ENCRYPTION_KEY — la credencial de R2 vive cifrada en su tabla secret
⚠️ VecindadEn ese mismo servidor está la base openfga (9,4 MB). Keycloak sí tiene el suyo aparte (postgres-wkk)
⚠️ ExposiciónAdemás del postgres.railway.internal privado, el servicio tiene proxy TCP público (reseau.proxy.rlwy.net:41704). OpenFGA, en cambio, sólo escucha en la red interna

⚠️ La base de datos del catálogo y la de la autorización comparten servidor.
No es un fallo de diseño —son bases separadas— pero sí es un radio de
explosión
: lo que tumbe ese Postgres deja el lakehouse sin verdad y sin
política a la vez.

⭐⭐ Qué guarda de verdad — y no es «un puntero»

La frase que llevamos meses repitiendo es «el catálogo guarda un puntero por tabla, y el commit es un compare-and-swap sobre él». Medido, es más que eso.

tabular.metadata_location  ← el puntero, sí existe: s3://…/metadata/00003-….json

…pero al lado, la metadata de Iceberg está descompuesta en tablas relacionales:

TablaFilasQué es
table_metadata_log851el historial de metadata files
table_snapshot625cada snapshot, con su manifest_list y su summary
table_properties613las propiedades por tabla
table_snapshot_log598el log de snapshots
table_schema450cada versión de esquema
table_sort_order241los sort orders
tabular / table / table_partition_spec194la tabla, su identidad y sus specs
table_refs181ramas y tags
endpoint_statistics1.381cuántas veces se llamó a cada endpoint

Y la prueba de que no es una caché: la columna table.metadata es un jsonb vacío en las 194 filas. Si esto fuera un índice sobre el fichero, el blob estaría ahí. No está: la forma normalizada es la única forma que hay dentro.

⭐ El metadata.json de R2 y las filas de este Postgres dicen lo mismo por dos
caminos
. Cuál de los dos manda —y por tanto qué pasa si divergen— es
exactamente la pregunta de la capa 2, y no se responde mirando el esquema:
se responde mirando el commit.

El tamaño, y lo que dice

El catálogo16 MB
El warehouse que describe18,77 MB

El registro pesa casi lo mismo que el dato registrado. No invalida la tesis —el catálogo crece con el número de tablas y de snapshots, no con las filas, y por eso un commit de 1 TB y uno de 1 KB le cuestan lo mismo— pero la afina:

Crece con la HISTORIA, no sólo con el inventario. 188 tablas producen 625
snapshots, 851 entradas de metadata-log y 598 de snapshot-log. Con 174 tablas de
mediana 1 snapshot y una de 195, lo que engorda esta base no es tener
muchas tablas: es reescribir mucho la misma.

Y eso enlaza con la retención: expirar snapshots no libera un byte de R2, pero sí encoge esto — que es, de las dos cosas, la que hoy está creciendo de verdad.


Capa 2 · El commit

① Dónde está el CAS — no donde lo buscábamos

Buscando el contador de versión que esperábamos encontrar en la tabla, aparece esto:

version : bigint   →  namespace · project · role · warehouse · generic_table
                      ⛔ NO existe en `table` ni en `tabular`

El bloqueo optimista con contador es para los objetos de gobierno del catálogo, no para las tablas. Lo que un commit de tabla mueve es otra cosa:

table_refs   PK (warehouse_id, table_id, table_ref_name)   →  snapshot_id
             179 refs `main`  +  2 ramas propias  ⭐

El «puntero» del que llevamos hablando es esta fila — la cabeza de la rama — y no el metadata_location. El CAS es el requirement de la spec Iceberg (assert-ref-snapshot-id) contrastado contra ese snapshot_id dentro de una transacción de Postgres. De ahí sale la propiedad que nos importa: un commit de 1 TB y uno de 1 KB cuestan lo mismo, porque lo que se compara y se escribe es un bigint.

Y aparecen dos ramas Iceberg propias que no estaban documentadas:
carbon-precomp-f3a67bae-… y carbon-precomp-afee2b56-…. Carbon crea ramas en
el catálogo, y eso no está escrito en ninguna pieza. Va a la lista de la capa 3.

② ⭐⭐ Quién manda, el fichero o la fila — resuelto

La capa 1 dejó la pregunta abierta y no se responde leyendo el esquema. Se responde poniendo las dos representaciones al lado:

npx tsx scripts/storage/diferencial-metadata.ts main.default ds_3f5ac26dea1f4fe8b4de094ec117487d
fichero en R2     31.255 B comprimidos → 238.046 B
respuesta REST                           238.046 B
¿idénticos byte a byte?  NO
primera divergencia en el byte 31.430 de 238.046:
  fichero  : …"snapshots":[{"snapshot-id":7863807021007333307,…
  respuesta: …"snapshots":[{"snapshot-id":2707857736196207888,…

Misma longitud, mismo contenido hasta el byte 31.430, y a partir de ahí el array de snapshots en otro orden. Y el control que lo cierra — cinco loadTable seguidos sobre la misma tabla:

llamada 1..5 : len=240.683 en las cinco · mismos tres primeros snapshots · CINCO HASHES DISTINTOS

⭐⭐⭐ El catálogo no sirve el fichero: lo reconstruye desde sus filas y lo
vuelve a serializar.
El metadata.json de R2 es una salida del commit, no
su fuente. La fuente es Postgres — que es lo que la capa 1 sospechaba por la
forma (44 tablas normalizadas, table.metadata jsonb vacío) y aquí queda medido.

Y tiene tres consecuencias prácticas, todas de la clase que muerde tarde:

loadTable no es determinista byte a byteCinco llamadas, cinco respuestas distintas con el mismo contenido. Cualquier hash, ETag, firma o caché por contenido sobre la respuesta del catálogo es papel mojado
«¿ha cambiado la tabla?» no se contesta comparandoComparar dos respuestas da siempre «sí». Se contesta mirando current-snapshot-id o la fila de table_refs, nunca el documento
⚠️ El fichero sigue importando para quien lea R2 directamenteUn motor que abra el metadata.json por su cuenta —los tres carriles que esquivan la cara— no está leyendo la fuente de verdad, está leyendo su última copia

③ La trampa de los 64 bits, reproducida sin querer

El primer intento de este diferencial comparó los dos lados con JSON.parse y dio todos los campos iguales. Lo eran: estaban mal los dos igual.

current-snapshot-id      827198859338249909
lo que JSON.parse deja:  827198859338249900   ⛔ MUTILADO

Los snapshot-id de Iceberg son enteros de 64 bits y en JS todo número es un double. Ésta es la causa exacta de los CatalogCommitConflicts sin concurrencia alguna que costaron el bloqueo de escritura: el motor commiteaba afirmando un id redondeado, y el CAS de §① —que compara un bigint— decía que no.

⇒ Por eso lib/governance/lossless-json.ts existe, y por eso el script permanente compara texto crudo. La trampa no está documentada por prudencia: está documentada porque volvió a picar hoy.

④ La idempotencia que el catálogo ofrece — y que no usa nadie

// GET /v1/config → overrides
{"idempotency-key-lifetime": "PT30M"}
idempotency_record   PK (warehouse_id, idempotency_key)0 filas

El catálogo trae el mecanismo estándar para que un reintento no commitee dos veces, lo anuncia en su /v1/config, y su tabla está vacía: ningún cliente nuestro manda la clave. (Lo que sí hay en el repo con ese nombre es la idempotencia del executor, de otra capa y otro problema.)

⚠️ Mientras tanto construimos el JOP para responder «¿aterrizó la mía?» con
operation_id en las propiedades del snapshot. No es que sobre —resuelve más
cosas— pero la pieza estándar para el caso simple está ahí, apagada, y eso
merece decidirse en vez de heredarse.

⑤ El commit de varias tablas existe

POST /v1/{prefix}/transactions/commit está entre los 25 endpoints que declara (§capa 5). La documentación general decía que Lakekeeper no lo tenía; gana el endpoint. Hoy no lo usa nadie: el replace de la ingesta consigue su atomicidad con una Transaction de PyIceberg sobre una tabla —delete-all + add_files, dos snapshots y un solo commit_table— que es un CAS único.

Y el reintento está declarado en la tabla, no en el cliente: commit.retry.* en 83 de las 188 tablas (las nacidas por _create_native_table).


Capa 3 · El ciclo de vida

① Las tres colas, y cuál de ellas trabaja

SELECT queue_name, status, count(*) FROM task_log GROUP BY 1,2;
ColaEjecuciones
task_log_cleanup44 · a diario desde el 2026-06-30limpia su propio registro
tabular_expiration14 con éxito (7-9 ago) · 2 canceladasla que de verdad borra
tabular_purgeCERO. Nunca ha corridodeclarada en /management/v1/info y sin una sola entrada

Y task_config está vacía: no hay configuración por warehouse ni por cola. Todo el ciclo de vida del catálogo corre con los valores por defecto, sin que nadie lo haya decidido.

Lo que hay en cola ahora mismo — y es lo que interesa:

tabular_expiration · scheduled · 2026-08-13T13:10 · task_data: { deletion_kind: 'purge' }   × 6

② ⭐⭐ El catálogo SÍ borra bytes — y esto corrige a DATA-STORAGE

La pieza del plano de bytes afirmó que «Lakekeeper tiene prohibido emitir DELETE contra R2» apoyándose en push-s3-delete-disabled: true. Es falso, y se comprueba con las 14 expiraciones que ya se ejecutaron: se listan sus prefijos en el bucket.

CONTROL POSITIVO · una tabla viva      683 obj · 4.867 KB   CONSERVA BYTES
las 14 que tabular_expiration ejecutó    0 obj              0 de 14 conservan bytes

14 de 14 sin un solo objeto, con control positivo al lado. Y descartada la explicación alternativa: en el repo no hay ninguna ruta que borre objetos de R2 salvo dos gates que limpian su propio prefijo de sondeo, y ninguno tocó estas tablas.

La evidencia estaba en el propio repo y no la miré.
scripts/governance/w1-sort-order-repair.ts lleva escrito, en mayúsculas, que un
DROP con purgeRequested=true borraría los Parquet — y por eso manda
false explícitamente y aborta si no puede. La afirmación de ayer contradecía una
advertencia que ya estaba escrita.

⇒ La imagen correcta del borrado: hay exactamente una vía que libera bytes, y es DROP con purga. push-s3-delete-disabled no significa «no borra».

③ ⛔⛔ La ventana de 7 días está vacía

El delete-profile es soft/604.800 s: durante siete días el catálogo dice que una tabla borrada se puede recuperar. Es el único colchón que tenemos — no hay Fail-safe como en Snowflake. Se comprueba igual, sobre las 6 que expiran mañana:

las 6 en BORRADO BLANDO (purga programada 2026-08-13)
   0 obj · 0 de 6 conservan bytes

⛔⛔⛔ Las seis tablas que el catálogo se compromete a poder restaurar hasta
mañana no tienen un solo byte en R2.
Un undrop devolvería la tabla y no el
dato: el registro, sus esquemas y su historia de snapshots apuntando a ficheros
que ya no existen.

Es el reverso exacto del riesgo de los dos pasos que ya conocíamos. Sabíamos que dropear sin borrar deja bytes huérfanos; esto es lo contrario y es peor: borrar los bytes a mano deja al registro prometiendo una recuperación que no puede cumplir, y sin ninguna señal de que no puede.

Y mañana a las 13:10 la purga correrá contra objetos que ya no están.

Las 2 canceladas del 2026-08-10 son la misma tabla dos veces: ds_ce4901bb… y ds_ce4901bb…__w1old. Es el ciclo de reparación W1 —drop sin purga, renombrar, recuperar—: recuperar o renombrar cancela la tarea programada.

⇒ El mecanismo de vuelta atrás funciona; lo que no existe es lo que devolvería los bytes.

⑤ Las dos ramas propias son TAGS, y son del JOP

carbon-precomp-f3a67bae-…   retention {"type":"tag"}   main.test.ds_38b26211…
carbon-precomp-afee2b56-…   retention {"type":"tag"}   main.test.ds_38b26211…
                            ambas creadas el 2026-08-10T14:51:54.572Z, `updated_at` NULL

Las crea services/ml-runner/app/routers/lakehouse.py:771tag = f"carbon-precomp-{body.operation_id}" — el verbo compensate. Y el tag no es decorativo: PyIceberg excluye de la expiración los snapshots con tag, así que la promesa de que un estado compensado sobrevive no descansa en una convención sino en el motor.

Carbon usa el catálogo como registro de su propia recuperación, y eso no
estaba escrito en ninguna pieza. Con una cola suelta: nadie las recoge. Dos
tags de agosto sin política de retención ni fecha de caducidad — y cada uno
ancla su snapshot para siempre.

⑥ Lo que no tiene cola en absoluto

Compactaciónno existe, ni cola ni política ejecutable
Ficheros sin referencia dentro de una tabla vivano existe. Es el único hueco donde el almacenamiento crece sin techo
Expiración de snapshotsla hacemos nosotros desde ml-runner, y es metadata-only: encoge esta base de datos, no el bucket

Capa 4 · Quién entra

① Ocho sujetos, y ni un solo rol

curl -sH "Authorization: Bearer $TOK" …/management/v1/user   # 8
curl -sH "Authorization: Bearer $TOK" …/management/v1/role   # {"roles":[]}
SujetoAlta
Victor Obregon — el único humano, y el único INSTANCE_ADMIN2026-07-02
…-operator · …-index-catalog · …-warehouse-writer · …-duck-serverago
⚠️ …-ml-runner · …-trino · …-tenant-provisionersin una sola concesiónjul-ago

roles: []. No hay ninguna indirección: cada permiso es una arista directa sujeto → warehouse. Con la consecuencia práctica de siempre — revocarle el acceso a un servicio no es una operación, son trece, una por warehouse.

Y hay tres cuentas sin nada asignado. …-ml-runner es un fósil: el servicio ya ni siquiera lleva credencial de catálogo en su entorno (0 coincidencias), porque dejó de ser el escritor. Nadie la dio de baja.

② La retícula: cuatro verbos y un patrón repetido nueve veces

warehouse-writer     create · describe · modify
operator             create · describe · modify
index-catalog        create · describe · modify · select     ← la cara
duck-server                                        select

Idéntico en 9 de los 13 warehouses. Y resuelve la rareza que dejó la capa 3 —«¿por qué el writer tiene modify y no select— con una prueba en vivo: el writer recibe credencial de storage igualmente.

loadTable CON delegación     operator: 200 +llave    writer: 200 +llave    duck: 200 +llave

select no es «poder leer el dato»: el acceso al dato lo abre tanto select como modify. Lo que la retícula separa de verdad es quien sólo consulta (select) de quien además escribe (modify) — y el nombre invita a leerlo mal.

③ ⭐⭐ Aplica de verdad — medido con control positivo y negativo

authz-backend: openfga es una configuración. Que aplique es otra cosa, y se pregunta lo mismo con tres identidades distintas:

prueba                                  operator    warehouse-writer   duck-server
listNamespaces           (compartido)   200         200                200
loadTable metadata       (compartido)   200         200                200
loadTable con delegación (compartido)   200 +llave  200 +llave         200 +llave
listar warehouses                       200         200                200
ver los USUARIOS         (servidor)     200         403                403     ⭐

El 403 es el primer corte real: la asignación de servidor —operator, que sólo tiene una cuenta— está aplicando. Y el corte a nivel de warehouse se ve en los cuatro que no tienen a nadie dentro:

warehouse                                       operator   writer   duck
lakehouse                        (control +)    200        200      200
carbon-prod-w-cccc70a5…                         200        404      404
carbon-prod-w-7b500f4d…                         200        404      404
carbon-prod-w-ec14e3cc…                         200        404      404
carbon-prod-w-0936227f…                         200        404      404

La retícula del catálogo aplica, y niega con 404, no con 403. No revela
la existencia de lo que no puedes ver. Es la decisión correcta — y hay que
saberla, porque un diagnóstico honesto leerá ese 404 como «no existe» y no como
«no puedes».

④ ⛔⛔ Los cuatro warehouses a los que apunta Index están desiertos

Los cuatro del 404 son exactamente los cuatro de la discrepancia de DATA-STORAGE: aquellos a los que apunta el warehouse_id de Index. Su única asignación es operator: ownership.

⛔⛔⛔ La mina es peor de lo que decía la otra pieza. No es sólo que el
prefijo no case: es que si dedicated_storage se activara y una tabla naciera
ahí, la ingesta recibiría un 404 del catálogo
— ni el writer ni la cara ni
duck tienen una sola concesión sobre esos cuatro.

Y el fallo se leería como «la tabla no existe».

⑤ Los cabos sueltos

⚠️ Una asignación a un sujeto que no existecarbon-test-w-7b500f4d… concede describe, select a oidc~trino-junctionun id que no está entre los 8 usuarios. Alguien lo escribió a mano
⚠️ Tres cuentas sin concesionesml-runner (fósil), trino, tenant-provisioner. La última provisiona warehouses y no tiene permiso de servidor para hacerlo: quien los creó fue otro
⚠️ Cero asignaciones de proyectoTodo cuelga del warehouse o del servidor. El nivel intermedio está sin usar
Un único administrador, y es una personaINSTANCE_ADMINS = ["oidc~3335…"] — la cuenta humana. No hay una identidad de rotura de cristal separada

Capa 5 · El contrato

① Lo que declara, y la trampa que lleva dentro

// GET /v1/config?warehouse=lakehouse
"overrides": { "uri": "https://…railway.app/catalog", "idempotency-key-lifetime": "PT30M" },
"defaults":  { "prefix": "4fb824d4-…", "rest-page-size": "100", "s3.delete-enabled": "false" },
"endpoints": [25]

La distinción no es cosmética: overrides manda sobre lo que el cliente tenga configurado; defaults sólo rellena huecos. Y en overrides va uri.

⭐⭐ Si la cara reenviara este /config tal cual, cada motor se
auto-redirigiría a Lakekeeper en su primera llamada
— y toda la gobernanza se
saltaría sola, por diseño del protocolo y sin que nadie hiciera nada mal.

Medido: no lo reenvía. Nuestro /config no lleva uri.

curl -s https://app.paladio.io/api/iceberg/v1/config
{"defaults":{},"overrides":{"oauth2-server-uri":"…/realms/lakehouse/…","scope":"openid"},
 "endpoints":[], "index-catalog":"declara el inquilino con `warehouse=w_<workspace…>`"}

② La cara cubre 13 de 25 — y falla cerrada

routeCatalogRequest reconoce 13 operaciones. Lo que el catálogo ofrece y la cara no enruta:

No enrutadoQué se pierde por la puerta
POST /v1/{prefix}/transactions/commitEl commit multi-tabla que la capa 2 encontró: existe, y por la puerta es inalcanzable
POST /v1/{prefix}/tables/rename · …/views/renameRenombrar. Lo hace el ciclo W1 — yendo directo a Lakekeeper
POST …/namespaces/{ns}/registerRegistrar una tabla existente. Lo usa register_service.py, también por fuera
GET …/tables/{table}/credentialsLa vía estándar y separada de pedir credencial. Un motor que la use en vez de la cabecera de delegación se queda sin llave
HEAD de namespace, tabla y vistaComprobar existencia
POST …/namespaces/{ns}/propertiesPropiedades de namespace
POST …/tables/{table}/metricsLos informes de escaneo del motor
POST …/views/{view}Actualizar una vista

Lo no enrutado se rechaza, no se reenvía:

if (!routed) return 404 'operación no gobernada: ${método} /v1/${ruta}'

Eso está bien —fallar cerrado es la decisión correcta— y tiene un precio que conviene decir en voz alta: la puerta es más estrecha que el catálogo, y las capacidades que hoy usamos de las bloqueadas (renombrar, registrar) funcionan porque van por fuera de ella. No falta la capacidad: falta por la puerta.

⚠️ Y el 404 vuelve a ser el mismo. «No existe» (capa 5), «no puedes»
(capa 4) y «no está enrutado» comparten código de error. Tres causas distintas,
un solo síntoma — y la primera hipótesis de cualquiera será siempre la
equivocada.

endpoints: [] — no declaramos nada

La spec REST usa ese campo desde 1.6 para que el cliente sepa qué pedir. El nuestro está vacío, así que un motor moderno sólo puede descubrir por ensayo y error… y todos los errores son 404. Declararlo con las 13 reales es barato y convierte un misterio en un contrato.

④ La paginación: a una tabla del corte

rest-page-size: 100          main.default: 99 tablas
con ?pageSize=10  →  next-page-token: SÍ, hay más

La paginación funciona y el catálogo la anuncia. El censo de DATA-STORAGE no la seguía: una tabla más en main.default y habría truncado en silencio — el mismo fallo que ya cometió al recorrer un solo warehouse, esperando a repetirse.

Arreglado hoy (censo-plano-bytes.ts sigue el next-page-token), con el mismo resultado: 188.

⑤ Capacidades completas y sin estrenar

Vistas7 endpoints en el catálogo, 4 enrutados por la cara, 0 vistas en todo el warehouse
Formato v3allowed-format-versions: [1,2,3], y las 188 tablas son v2
Métricas de escaneoel catálogo acepta los informes que los motores emiten; nadie los manda, y son la materia prima de cualquier decisión de compactación
Idempotenciaanunciada (PT30M), tabla vacía — capa 2·④

⑥ Qué de todo esto nos ata

No existe …/planSin server-side scan planning la planificación la paga el cliente leyendo 1.271 manifiestos. Es además la costura por donde entraría la política de celda: sin ese endpoint, no hay dónde ponerla
⛔⛔ El catálogo es la fuente, no un índice (capa 2)Cambiar de catálogo no es copiar ficheros: los metadata.json de R2 son salidas, y el estado vivo —refs, snapshots, logs, propiedades— está en 44 tablas de un Postgres. Migrar es reconstruir, y hoy nadie lo ha ensayado
🟡 Un contrato estándar de verdadSalvo …/plan, lo que declara es la spec. Un motor cualquiera habla con esto sin saber que existimos — que era el objetivo, y se cumple

Lo que falta — con su gate

QuéGate
⛔⛔Recuperación real: o el borrado blando protege bytes, o se deja de prometer 7 díasDropear una tabla, esperar, undrop, y leer sus filas
⛔⛔Dar de alta al writer y a la cara en los 4 warehouses carbon-prod-w-*, o retirarlosEl 404 de la capa 4 pasa a 200 para warehouse-writer
Mandar la clave de idempotencia en el commit, o decidir que el JOP la sustituyeUn reintento del mismo commit con idempotency_record a 1 fila
Declarar endpoints[] en nuestra cara/api/iceberg/v1/config lista las 13
Recoger los tags carbon-precomp-…Un barrido que los caduque sin tocar los vivos
🟡Roles en vez de 13 aristas por sujetoRevocar el acceso de un servicio en una operación
🟡Un oidc~trino-junction que no existeQue las asignaciones sólo nombren sujetos del censo

Historial

VersiónFecha
v1.02026-08-12Capa 5 y pieza cerrada. El contrato: overrides.uri apunta a Lakekeeper ⇒ reenviar el /config tal cual haría que cada motor se auto-redirigiese fuera de la puerta (medido: no lo reenvía). La cara cubre 13 de 25 endpoints y falla cerrada — pero renombrar y registrar funcionan hoy porque van por fuera, y el commit multi-tabla es inalcanzable por la puerta. rest-page-size: 100 con main.default a 99 tablas: el censo de DATA-STORAGE no seguía la paginación y estaba a una tabla de truncar en silencio — arreglado. Y lo que ata: sin …/plan la planificación la paga el cliente (1.271 manifiestos), y como el catálogo es la fuente y no un índice, migrar de catálogo es reconstruir, no copiar
v0.42026-08-12Capa 4 medida. La retícula del catálogo APLICA —probado con control positivo y negativo sobre tres identidades— y niega con 404, no con 403, así que un fallo de permiso se lee como «no existe». ⛔ Y agrava la mina de DATA-STORAGE: los 4 warehouses a los que apunta Index no tienen ninguna concesión — ni el writer ni la cara ni duck pueden verlos. Además: 8 sujetos y roles: [] (revocar a un servicio son 13 operaciones), 3 cuentas sin concesiones —una de ellas un fósil sin credencial—, una asignación a oidc~trino-junction, un id que no existe, y la aclaración de que select no es «poder leer»: modify también entrega la llave del dato
v0.32026-08-12Capa 3 medida. ⛔ El hallazgo grave: la ventana de recuperación de 7 días está VACÍA — las 6 tablas que el catálogo se compromete a restaurar hasta mañana no tienen un solo byte en R2, así que un undrop devuelve el registro y no el dato. Y corrige a DATA-STORAGE: el catálogo SÍ borra bytes (14 de 14 expiradas sin objetos, con control positivo) — push-s3-delete-disabled no significa «no borra», y la advertencia ya estaba escrita en w1-sort-order-repair.ts. Además: tabular_purge no ha corrido nunca, task_config está vacía (todo por defecto), y las 2 ramas carbon-precomp-… son tags del compensate del JOP que nadie recoge
v0.22026-08-12Capa 2 medida. La pregunta que dejó abierta la capa 1 queda resuelta con un diferencial en frío: el catálogo NO sirve el metadata.json, lo reconstruye — misma longitud, otro orden de snapshots, y cinco loadTable seguidos dan cinco respuestas distintas ⇒ el fichero de R2 es una salida del commit, no su fuente, y ningún hash sobre la respuesta del catálogo sirve. Además: el CAS es sobre table_refs, no sobre un contador de versión (que sólo existe en los objetos de gobierno); idempotency_record está vacía mientras el catálogo anuncia PT30M; y aparecen 2 ramas Iceberg propias carbon-precomp-… que no estaban en ninguna pieza
v0.12026-08-12Capa 1 medida. El hallazgo: no guarda un puntero — guarda la metadata Iceberg normalizada en 44 tablas, con el jsonb de respaldo vacío en las 194 filas. Y dos hechos de sustrato que no estaban escritos: su Postgres comparte servidor con el de OpenFGA y está expuesto por proxy TCP público, y el catálogo pesa casi lo mismo (16 MB) que el warehouse que describe (18,77 MB) — porque crece con la historia, no con el inventario