📒 PIEZA · CATALOG-STORE — quién guarda la verdad de una tabla
| Versión | v1.0 — las cinco capas |
| Estado | 🏁 Trazada y medida · ⛔ la ventana de recuperación está vacía |
| Última medición | 2026-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
| Lakekeeper | 0.13.0 · licencia Apache-2.0 · bootstrapped: true |
| Dónde | Railway, proyecto cozy-comfort — el mismo donde viven el writer, Duck, Keycloak y OpenFGA |
| Identidad del servidor | server-id 019f17ea-… · un solo proyecto, el Default Project (0000…) |
| Colas declaradas | tabular_expiration · tabular_purge · task_log_cleanup |
| Backend de autorización | openfga — 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
| Motor | PostgreSQL 18.4, servicio Postgres de Railway, volumen propio |
| Base | railway · 16 MB · 44 tablas, todas en public |
| Pools | PG_DATABASE_URL_READ y _WRITE … al mismo servidor: son dos pools, no hay réplica de lectura |
| Secretos | PG_ENCRYPTION_KEY — la credencial de R2 vive cifrada en su tabla secret |
| ⚠️ Vecindad | En ese mismo servidor está la base openfga (9,4 MB). Keycloak sí tiene el suyo aparte (postgres-wkk) |
| ⚠️ Exposición | Ademá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:
| Tabla | Filas | Qué es |
|---|---|---|
table_metadata_log | 851 | el historial de metadata files |
table_snapshot | 625 | cada snapshot, con su manifest_list y su summary |
table_properties | 613 | las propiedades por tabla |
table_snapshot_log | 598 | el log de snapshots |
table_schema | 450 | cada versión de esquema |
table_sort_order | 241 | los sort orders |
tabular / table / table_partition_spec | 194 | la tabla, su identidad y sus specs |
table_refs | 181 | ramas y tags |
endpoint_statistics | 1.381 | cuá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.jsonde 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álogo | 16 MB |
| El warehouse que describe | 18,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-…ycarbon-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. Elmetadata.jsonde 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.metadatajsonb 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 byte | Cinco 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 comparando | Comparar 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 directamente | Un 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_iden 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;
| Cola | Ejecuciones | |
|---|---|---|
task_log_cleanup | 44 · a diario desde el 2026-06-30 | limpia su propio registro |
tabular_expiration | 14 con éxito (7-9 ago) · 2 canceladas | la que de verdad borra |
⛔ tabular_purge | CERO. Nunca ha corrido | declarada 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.tslleva escrito, en mayúsculas, que un
DROPconpurgeRequested=trueborraría los Parquet — y por eso manda
falseexplí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. Unundropdevolverí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.
④ Qué cancela una expiración — el borrado blando sí es reversible en el catálogo
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:771 —
tag = 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ón | no existe, ni cola ni política ejecutable |
| Ficheros sin referencia dentro de una tabla viva | no existe. Es el único hueco donde el almacenamiento crece sin techo |
| Expiración de snapshots | la 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":[]}
| Sujeto | Alta |
|---|---|
Victor Obregon — el único humano, y el único INSTANCE_ADMIN | 2026-07-02 |
…-operator · …-index-catalog · …-warehouse-writer · …-duck-server | ago |
⚠️ …-ml-runner · …-trino · …-tenant-provisioner — sin una sola concesión | jul-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 con403. 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 sidedicated_storagese activara y una tabla naciera
ahí, la ingesta recibiría un404del 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 existe | carbon-test-w-7b500f4d… concede describe, select a oidc~trino-junction — un id que no está entre los 8 usuarios. Alguien lo escribió a mano |
| ⚠️ Tres cuentas sin concesiones | ml-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 proyecto | Todo cuelga del warehouse o del servidor. El nivel intermedio está sin usar |
| Un único administrador, y es una persona | INSTANCE_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
/configtal 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
/configno llevauri.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 enrutado | Qué se pierde por la puerta |
|---|---|
⭐ POST /v1/{prefix}/transactions/commit | El commit multi-tabla que la capa 2 encontró: existe, y por la puerta es inalcanzable |
⭐ POST /v1/{prefix}/tables/rename · …/views/rename | Renombrar. Lo hace el ciclo W1 — yendo directo a Lakekeeper |
⭐ POST …/namespaces/{ns}/register | Registrar una tabla existente. Lo usa register_service.py, también por fuera |
⭐ GET …/tables/{table}/credentials | La 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 vista | Comprobar existencia |
POST …/namespaces/{ns}/properties | Propiedades de namespace |
POST …/tables/{table}/metrics | Los 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
404vuelve 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
| Vistas | 7 endpoints en el catálogo, 4 enrutados por la cara, 0 vistas en todo el warehouse |
| Formato v3 | allowed-format-versions: [1,2,3], y las 188 tablas son v2 |
| Métricas de escaneo | el catálogo acepta los informes que los motores emiten; nadie los manda, y son la materia prima de cualquier decisión de compactación |
| Idempotencia | anunciada (PT30M), tabla vacía — capa 2·④ |
⑥ Qué de todo esto nos ata
⛔ No existe …/plan | Sin 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 verdad | Salvo …/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ías | Dropear 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 retirarlos | El 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 sustituye | Un 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 sujeto | Revocar el acceso de un servicio en una operación |
| 🟡 | Un oidc~trino-junction que no existe | Que las asignaciones sólo nombren sujetos del censo |
Historial
| Versión | Fecha | |
|---|---|---|
| v1.0 | 2026-08-12 | Capa 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.4 | 2026-08-12 | Capa 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.3 | 2026-08-12 | Capa 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.2 | 2026-08-12 | Capa 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.1 | 2026-08-12 | Capa 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 |