Published

🚰 PIEZA · INGESTION — de la fuente al bucket

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 · INGESTION — de la fuente al bucket

Versiónv1.2 — el brazo PG RETIRADO; queda UN camino, y es transitorio
Estado⚠️ Vive en el paradigma ANTERIOR al cluster · 🏁 el camino a la cara, despejado · ⏳ con fecha de caducidad: ingesta-como-query-approach.md
Última medición2026-08-13scripts/warehouse/i0-ingesta-por-la-cara.ts

Qué es

El único camino por el que entra dato nuevo al lakehouse. Lee una fuente
externa, acuña su coordenada en Index y aterriza Parquet en R2.

Qué NO es

  • No es Spark. El cómputo del cluster no participa: la extracción es Node y la escritura es Python/PyIceberg. La ingesta se quedó en el paradigma anterior.
  • No es el write-path del SQL Editor. Aquél entra por la puerta (Junction) y escribe con el motor. Éste es un carril separado, con otras piezas y otras garantías.
  • No está gobernada por la cara. Ver §«Lo que el trazado destapa».

El flujo, tal como está en el código

 ① FUENTE — el Postgres del cliente
    credencial en Index · su `metadata` lleva el destino (catálogo + esquema)
 ② EXTRACCIÓN — Node
    lib/workers/polling/postgresql-polling-worker.ts   → PostgreSQLPollingWorker
    lib/workers/polling/base-polling-worker.ts         → el motor común de sondeo
    lib/workers/adapters/database/postgresql-adapter.ts → introspección + lotes
    ⭐ v7: `fetch batch → yield` — streaming. El límite en Node es UNA fila
 ③ COORDENADA — Index          lib/workers/polling/dataset-writer.ts
    findOrCreateDataset() ─▶ acuña (workspace, schema_id, name)
    ⭐ Aquí, y sólo aquí, la ingesta toca gobernanza: el dataset NACE con su sitio
 ④ EL DESVÍO                   resolveDatasetRowSink(dataset)
    └── 'iceberg'  ⇒ el ÚNICO camino. El destino lo decide la CAPACIDAD del
                     puerto (`write-router:102` → `mode = spec.maxMode`), no un
                     flag, y los tres del sync declaran `iceberg_native`
    🪦 'pg'        ⇒ RETIRADO el 08-13. Hoy lanza (ver §②)
 ⑤ TRANSPORTE — Node → Python  lib/lakehouse/iceberg-native-write.ts
    writeIcebergNativeSnapshot  (un POST)   ·  writeIcebergNativeFanout (por particiones)
    lib/lakehouse/ingest-client.ts
      └─ POST {WAREHOUSE_WRITER_URL}/lakehouse/ingest   · NDJSON en streaming
         (meta + una fila por línea; Node nunca sostiene el dataset en memoria)
 ⑥ ESCRITURA — warehouse-writer (Railway)  services/ml-runner/app/lakehouse/writer.py
    PyIceberg · build_catalog(type='rest', uri = LAKEHOUSE_REST_URI)
    ⛔ LAKEHOUSE_REST_URI = https://heroic-victory-…/catalog  →  LAKEKEEPER DIRECTO
    PyArrow → Parquet   ·   catálogo → commit del snapshot
 ⑦ BYTES — Cloudflare R2 (bucket `lakehouse`, WEUR)

De dónde sale la credencial, y quién decide su destino

 ⓪ EL ALTA — app/api/integrations/[service]/connect/route.ts
    Paso 1 del asistente → `user_credentials`  (encrypted_data · workspace_id · created_by)
    Paso 2 del asistente → el DESTINO en el Warehouse (catálogo + esquema)
       └─ se guarda en `user_credentials.metadata.warehouse_destination`
          { catalogId · schemaId · catalogName · schemaName }
 ③′ QUIÉN LO LEE — DatasetWriter.ensureWorkspaceId()
    lee la credencial UNA vez y se lleva de paso el destino
       └─ ensureTargetSchemaId():
            1. `warehouse_destination.schemaId`   ← lo que eligió el usuario
            2. si no hay → el `main.default` del workspace   ← decisión IMPLÍCITA

El destino lo decide el usuario en el alta, y si no lo decide, lo decide el código. No hay un tercer sitio: la coordenada se acuña en findOrCreateDataset con lo que salga de ahí.

Lo medido (2026-08-12)

user_credentials ............ 26
   con warehouse_destination .  9      ⇒ 17 fuentes (65 %) SIN destino elegido
datasets sin schema_id ......  0      ✅ la coordenada está completa

⚠️ Las 17 sin destino no están rotas: están en main.default. Pero es un destino que nadie eligió — y como el paso 2 del asistente es opcional y se persiste best-effort (un fallo sólo escribe un warn), la diferencia entre «lo eligió» y «se lo pusimos» no queda registrada en ninguna parte.

⛔ Y un bug real en el camino de respaldo

dataset-writer.ts resuelve el workspace desde la credencial; si no la hay, cae a «el workspace que este usuario posee»:

.from('workspace_members').select('workspace_id')
.eq('created_by', this.userId)      // ⛔ esa columna NO EXISTE
.eq('role', 'owner')

workspace_members es (workspace_id, user_id, role, invited_by, invited_at, joined_at) — no tiene created_by. Quien lo tiene es user_credentials, de donde probablemente se copió. El predicado correcto es user_id.

⇒ Ese respaldo no puede funcionar nunca, y el error que produce —«No credential and no owned workspace found»manda a buscar al sitio equivocado: dice que no hay workspace cuando lo que hay es una columna mal escrita. Hoy no muerde porque todas las rutas llegan con credencial.


Cómo se descubre el esquema, y cómo se traducen los tipos

 ②′ DESCUBRIMIENTO — lib/workers/adapters/database/postgresql-adapter.ts
    information_schema.tables  ⚠️ MULTI-ESQUEMA a propósito: NO se ancla al
                                  `schema` configurado en la credencial
    + FKs en una sola consulta (table_constraints ⋈ key_column_usage ⋈
      constraint_column_usage) — con su bandera de «la introspección falló»

 ②″ TRADUCCIÓN — dos saltos y medio, y cada uno tiene dueño distinto
    ① tipo SQL de la fuente ──normalizeDbType──▶ BASE_TYPE   (el tipo lógico de Carbon)
    ② BASE_TYPE ──BASE_TYPE_TO_ICEBERG──▶ tipo Iceberg        lib/types/iceberg-types.ts
    ③ tipo Iceberg ──────────────────────▶ pyarrow            ml-runner (mecánico)

El reparto de dueños está bien pensado, y conviene decirlo: TypeScript posee el mapeo de dominio (BASE_TYPE → Iceberg) porque deriva con BASE_TYPES; ml-runner posee el mecánico (Iceberg → pyarrow) porque es la spec y no deriva. La consecuencia buscada: ml-runner es un ejecutor tonto que no sabe nada de BASE_TYPES.

Lo que se pierde por el camino

QuéConsecuencia
Un tipo desconocido cae a stringDEFAULT_ICEBERG_TYPE, sin avisoLa tabla se crea igual, con una columna de texto donde había otra cosa. Nada lo registra
Decimal → decimal(38,9), escala FIJAUna fuente numeric(20,10) pierde un dígito de escala, en silencio
⚠️12 tipos complejos → string JSONObject · Map · Array · Vector · TimeSeries · GeoPoint · Geohash · Geometry · Geoshape · Attachment · Media · MediaReferenceEl lakehouse no ve estructura, ve texto. Se decodifica al leer, pero el SQL no puede operar sobre ellos: no hay WHERE datos->'campo'
⚠️Timestamp → timestamptz siempreUn timestamp without time zone de la fuente sale marcado con zona
Byte/ShortintCorrecto y documentado: Iceberg no tiene enteros de 8/16 bits

⛔ Y el espejo que sostiene un comentario

La lista de los 12 tipos JSON existe dos veces: JSON_ENCODED_BASE_TYPES en lib/types/iceberg-types.ts y en services/ml-runner/app/lakehouse/decode.py, con un # MUST mirror encima.

Hoy coinciden — verificado, 12 y 12, mismos nombres. Pero:

# los trinquetes que SÍ existen en el repo:
check:sdk-spec · check:pipeline-drift · check:iceberg-freshness · check:dataset-rows-seam
check:storage-perimeter · check:cedar · check:namespace-resolution · check:dataset-births
check:row-index-seam · check:catalog-sql · check:carbon-sql-contract · check:plan-diferencial
# ⛔ ninguno compara estas dos listas

El mismo conocimiento en dos lenguajes, sostenido por un comentario. Es la clase de bug que este repo ya documenta que le costó una fase — y aquí el fallo sería especialmente feo: si divergen, una columna se escribiría codificada y se leería sin decodificar (o al revés), devolviendo texto JSON donde el usuario espera un objeto, sin ningún error.

⭐⭐ Y aquí es exactamente donde NACERÍA el contrato

Este tramo ya sabe lo que un contrato ODCS necesita: los nombres, los tipos de origen, la nulabilidad (IcebergIngestField.nullable) y las claves foráneas. Todo eso se resuelve, se usa para crear la tabla… y se tira: acaba como un schema en datasets, no como un contrato con semántica, calidad y clasificación.

No falta información para el contrato. Falta el objeto donde ponerla.


El commit — y quién reconcilia

El plano de control envuelve al de datos, y está factorizado en una sola función (withNativeControlPlane) que comparten el POST único y el fan-out: sólo difiere el paso 2.

 ⑥′ withNativeControlPlane      lib/lakehouse/iceberg-native-write.ts
    1. abre la txn en `dataset_transactions`            → status 'open'
    2. ESCRIBE el snapshot  (el único paso que difiere entre POST único y fan-out)
    3. commitea la txn                                  → status 'committed'
    4. registra en `iceberg_sync_log` = 'succeeded'     ← lo que hace que el oráculo
                                                          de frescura diga «al día»
    5. actualiza `datasets` (row_count · schema · size · FKs · versión de esquema)

⭐⭐ Y la pieza que hace posible reconciliar: el operationId es el id de esta misma txn de ledger, y se graba DENTRO del commit Iceberg (snapshot_properties) junto a los datos. ⇒ la pregunta «¿aterrizó la MÍA?» se le contesta al catálogo, no por correlación temporal.

⭐ Los tres estados terminales, y el que ya no se escribe

EstadoQué significa
committedEl snapshot existe y quedó registrado. Único desenlace feliz
open⚠️ «NO LO SÉ». El snapshot puede existir —o existe seguro— y el ledger no pudo apuntarlo
abortedEsta función ya NO lo escribe nunca. No puede: su catch cubre el data-plane y el registro, así que jamás está en posición de DEMOSTRAR que nada aterrizó

Ese tercer punto es una reparación de diseño que merece nombrarse: el estado
terminal dejó de inferirse del control-flow
(«¿llegué al catch?»), porque el
control-flow no lo sabe. Y ControlPlaneUnrecordedError lo dice entero: «el
snapshot SE COMMITEÓ pero el control-plane no pudo registrarlo… NO reintentar a
ciegas: los datos ya están escritos.»

Fail-loud, sin respaldo en Postgres — deliberadamente, para no crear un split-brain.

⛔⛔ Y el cierre del recorrido: el reconciliador existe y NO SE EJECUTA

lib/compute/ratify.ts tiene los verbos (ratifyTransaction, ratifyOpenTransactions), con su barrido, su antigüedad mínima, su tope de abortos por ciclo y su dryRun. Está probado (ratify-sweep.test.ts).

grep -rn "ratifyOpenTransactions" app/ lib/ scripts/ services/ | grep -v ratify
# → SÓLO su propio test. Ni cron, ni worker, ni endpoint.

Y la ventana ya mordió (medido 2026-08-12):

dataset_transactions   committed 961 · aborted 22 · ⚠️ open 5   (del 11 y 12 de agosto)
iceberg_sync_log       succeeded 223 · failed 13 · pending 1
⚠️ txn 'open' con más de 1 hora, candidatas a reconciliación: 5

Cinco escrituras en estado «no lo sé», de ayer y de hoy, con su reparador escrito, probado y sin llamar. No es una debilidad de diseño: el diseño está resuelto. Es un barrido sin disparador — la mitad más barata y la que falta.


Lo que el trazado destapa

⛔⛔ ① La ingesta NO pasa por la cara — ninguna puerta la gobierna

El escritor de producción lee LAKEHOUSE_REST_URI de su propio entorno de Railway, y apunta a Lakekeeper directamente, no a /api/iceberg.

railway variables -s warehouse-writer | grep LAKEHOUSE_REST_URI
# → https://heroic-victory-production-…   (Lakekeeper, no la cara)

Las dos puertas de gobernanza se saltan enteras: ni la de la cara («¿puede este motor?») ni la de Junction («¿puede esta persona?»). La ingesta escribe con la identidad lakekeeper-warehouse-writer contra el catálogo, y nadie le pregunta nada.

Es exactamente la advertencia que catalog-authz.ts lleva escrita en su cabecera:
«No cierra la red. Si los motores pueden seguir apuntando a Lakekeeper
directamente, esto no aplica nada.»
No era una hipótesis: es el camino por el
que entra todo el dato.

Y desde el 08-13 se sabe lo que cuesta arreglarlo, y es mucho menos de lo que parecía. Ver §«I·0 · el coste, medido».

🏁 ② El sink pg — RETIRADO (2026-08-13)

dataset-writer.ts conservaba un motor de escritura completo contra dataset_rowsPostgresBackend con INSERT multi-fila, UPSERT por (dataset_id, row_index) y COPY FROM STDIN con tabla temporal, más writeDatasetRowsPg, el control-plane que compartía con la puerta—. Esa tabla no existe desde el 2026-07-31 (migración 20261262), y su propio log seguía diciendo «dataset_rows WILL be written».

Era inalcanzable, y eso es lo que hizo barata la retirada. write-router.ts:102 hace mode = spec.maxMode sin consultar nada —la máquina de flags se fue con el plano PG— y los tres puertos del sync (sync.full · sync.incremental · sync.append) declaran iceberg_native.

⇒ Se retiró entero: −808 líneas en 6 ficheros, check:dataset-rows-seam 9 → 0 para este fichero. Y cayó con él el brazo postgres de write() en lib/compute/junction.ts, que delegaba aquí — ningún writerId que cruce la puerta puede resolver a pg (los tres que lo hacen declaran iceberg_native).

Lo que queda en su sitio es un error ruidoso, no un hueco: un sink=pg en el sync o en la puerta lanza nombrando la causa y el fichero donde se arregla. Aceptar la escritura sería prometer una durabilidad que no existe.

⚠️ Un peligro descrito de más desordena las prioridades tanto como uno descrito de
menos.
La v1.0 de este doc decía que la rama pg era una mina armada; la v1.1 lo
corrigió a inalcanzable. Ninguna de las dos versiones estaba tomando la decisión
correcta, que era borrarla: código muerto que se lee como opción disponible es
deuda de lectura aunque nunca se ejecute.

⚠️ Corrección de v1.1 — ingest.stream YA estaba cerrado

La v1.1 decía que ingest.stream «no está cerrado y ejecuta INSERT INTO dataset_rows contra una tabla que no existe». Es falso, y el código lo desmiente: app/api/datasets/ingest/route.ts:370-381 hace void batch y devuelve 410 ingest_stream_closed antes de tocar nada. Se cerró en D·2, con su comentario y su mensaje. El trinquete lo dice hasta en la línea del baseline («Apretado 4→3 (D·2): ingest.stream cerrado (410), su INSERT se fue») — se leyó la tabla de puertos (maxMode: 'pg') y no la ruta.

⇒ De los puertos con maxMode: 'pg': row.edit cerrado (409), ingest.stream cerrado (410), dataset.manifest mudado a Index (dataset_file_manifest, mig. 20261258). Ninguno alcanza el plano borrado.

El único resto con pulso es handleAbortTransaction (ingest/route.ts:434): un .delete() sobre dataset_rows cuyo error no se comprueba, así que falla en silencio. Cosmético, pero es lo que queda.

⚠️ ③ Spark no está en el camino

Ni Spark Connect ni el puente participan. La ingesta es Node (extracción) + PyIceberg (escritura), y el cluster no se entera. ⇒ el paradigma de cómputo del cluster cubre el SQL Editor y no la ingesta, que es donde entra el 100 % del dato nuevo.

✅ ④ Lo que sí está bien resuelto, y no hay que tocar

  • El streaming es real: fetch batch → yield en la extracción y NDJSON en el transporte. Una sincronización de millones de filas no cabe en memoria en ningún punto, y eso ya está medido y en producción.
  • La coordenada se acuña en Index (findOrCreateDataset), no en el motor ni en el catálogo. Es el único punto donde la ingesta toca gobernanza, y está en el sitio correcto.
  • El fan-out por particiones para tablas grandes existe y es paralelo.

🏁 I·0 · EL COSTE DE ENTRAR POR LA CARA, MEDIDO (2026-08-13)

npx dotenv -e .env.local -- npx tsx scripts/warehouse/i0-ingesta-por-la-cara.ts

123 datasets · 1 inquilino CON DATO de 8 workspaces   ⚠️ los otros 7 están vacíos
warehouse que manda hoy la ingesta ...... null   ← el único con dato NO tiene espacio propio
namespaces: opacos 7 · legacy 96 · sin estampar 20
grants de `lakekeeper-warehouse-writer` . ✅ createTable · loadTable · updateTable

⭐⭐ El hallazgo: el ÁMBITO decide, y el bloqueo es UNA operación

Contra la cara en producción, con y sin prefijo de inquilino:

OperaciónÁmbitocon prefijosin prefijo
loadTabletabla200200
listTables (= la resolución de createTable)namespace200403

Las de ámbito TABLA se salvan sin prefijo y con namespace legacy, porque la cara saca el inquilino del propio ds_<hex> resolviéndolo en Index (route.ts:207-243) — una tercera vía al inquilino que ningún doc había nombrado. Las de ámbito NAMESPACE no tienen tabla que resolver, y createTable es de ésas.

Apuntar hoy la ingesta a la cara no la tiraría: la dejaría MEDIO VIVA. Todo
lo que ya existe seguiría commiteando y sólo reventaría la tabla que nace — así
que el síntoma aparecería el día que alguien conecta una fuente nueva, lejos del
cambio que lo causó.

(Se midió con listTables y no con createTable a propósito: es GET, no muta, y recorre exactamente la misma resolución de inquilino. Medir el nacimiento de una tabla creándola sería medirlo rompiendo la regla de la sonda.)

La reparación, y está en un sitio

ingest-client.ts mandaba tenantWarehouseNameForDataset — el espacio de almacenamiento de Lakekeeper (carbon-{env}-w-{hex}, y null para el compartido). La cara quiere el inquilino (w_{hex}), que existe siempre.

No es traducir: son dos cosas distintas con el mismo nombre de campo. Y el
null lo prueba — por Lakekeeper significa «el compartido»; por la cara habría
significado «sin inquilino» ⇒ 403 al crear.

🏁 Hecho (lib/lakehouse/warehouse-token.ts, 7 tests). Y sin interruptor: el escritor ya publica a quién apunta (GET /lakehouse/configrest_uri_host), así que el destino se deriva de él. Un INGESTA_POR_LA_CARA=true habría puesto el mismo hecho en dos sitios, y el día que discreparan ganaría el equivocado.

⭐ Lo que NO bloquea, y se creía que sí

  • El namespace legacy (116 de 123 datasets). Con prefijo, la cara no mira el nombre del namespace ⇒ la migración N·1 no es prerrequisito de esto. Estaba escrito en catalog-tenant.ts:25 y ahora está medido.
  • Los grants del writer: están. ⚠️ Pero sobre 1 de 8 workspaces — los otros 7 no tienen dato, así que no hay caso que medir (regla 54: una sonda que no llega al caso no lo aprueba, lo ignora).

⚠️ Dos reservas de instrumento, dichas

  1. El token de la sonda es de lakekeeper-operator, no del writer. Por eso el veredicto de permisos se apoya en la decisión compuesta contra Index, que sí es autoritativa, y no en el HTTP.
  2. Un 502 en la primera pasada («el emisor rechazó la credencial de la cara») no se reprodujo al reintentar. Queda como transitorio, no como hallazgo.

⛔ Y un hueco que apareció de paso

fanout-client.ts —el camino de las tablas grandes— no manda warehouse en absoluto: la ingesta particionada aterriza en el espacio que diga el entorno del escritor, no en el del inquilino. Es anterior a esto y no lo arregla I·1.


Lo que falta — con su gate

QuéGate
🏁I·0 · ¿qué cuesta meter la ingesta por la cara?i0-ingesta-por-la-cara.tsCONTESTADA 08-13
🏁I·1·a · que el token de inquilino se derive del destinowarehouse-token.ts · 7 tests, con control negativo
⛔⛔I·1·b · apuntar LAKEHOUSE_REST_URI de warehouse-writer a /api/icebergBLOQUEADO POR EL RELEASE, no por la variable — ver §I·1·b
⛔⛔I·2 · ¿firma la cara los commits de upsert?Contestado en código: SÍ debería (§I·2). Falta medirlo, y medirlo exige I·1·b
🏁Retirar el sink pg y su SQL contra dataset_rowsHECHO 08-13check:dataset-rows-seam 9 → 0 en dataset-writer.ts, y el trinquete lo mantiene
🏁ingest.stream: cerrarlo como se cerró row.editYA ESTABA — 410 desde D·2 (ingest/route.ts:370). Era un error de este doc, no una tarea
El .delete() mudo de handleAbortTransaction (ingest/route.ts:434)El último acceso ejecutable a la tabla borrada. Falla en silencio
⛔⛔Darle un disparador al barrido de ratificación — hoy ratifyOpenTransactions sólo lo llama su test, y hay 5 txn open esperandoUn cron/worker que lo corra · y la cola de open con más de 1 h a 0
created_byuser_id en el respaldo de ensureWorkspaceIdUn test que resuelva el workspace sin credencial
🟡Registrar si el destino se ELIGIÓ o se asumióHoy 17 de 26 fuentes están en main.default sin que nadie lo decidiera, y no hay forma de distinguirlo
Trinquete del espejo TS↔Python de JSON_ENCODED_BASE_TYPESUn check: que falle si divergen. Hoy sólo lo sostiene un comentario
Registrar la pérdida de tipo — desconocido→string, decimal(38,9)Un aviso en el nacimiento del dataset cuando la traducción no es fiel
🟡Contrato en la ingesta (ODCS) — que el dataset nazca con su semántica y sus etiquetasUn dataset ingerido llega con clasificación, no sólo con columnas. ⭐ La información ya se resuelve (tipos, nulabilidad, FKs): falta el objeto donde ponerla
¿Debe la ingesta pasar a Spark?YA NO ESTÁ ABIERTA. El motivo medido apareció: numeric(20,10) pierde el décimo decimal por este carril y llega entero por el de Spark. No es uniformidad, es fidelidad — ver ingesta-como-query-approach.md §4·bis

⛔⛔ I·1·b — BLOQUEADO, y no por lo que parecía (2026-08-13)

La variable está identificada y medida:

railway variables -s warehouse-writer
  LAKEHOUSE_REST_URI       = https://heroic-victory-…/catalog     ← Lakekeeper
  LAKEHOUSE_REST_WAREHOUSE = lakehouse

Pero el código de I·1·a no puede llegar a donde corre la ingesta. Medido:

Carbon Jobs (el servicio que corre los workers de polling)
  despliega desde GitHub · branch main · commit 76cb381 (2026-08-09)
origin/main == 76cb381                     ⇐ EXACTAMENTE lo que corre
esta rama .. 142 commits POR DELANTE de main

⇒ El derivador vive en una rama que el worker no ve. Apuntar la variable hoy pondría la cara delante de un worker que sigue mandando el token de Lakekeeper ⇒ 403 en createTable: el fallo medio-vivo, provocado a propósito.

Y el reparto de despliegues no es uniforme, que es lo que lo hace fácil de no ver: Vercel se despliega desde el árbol local (por eso hay que hacerlo desde un worktree limpio), pero Carbon Jobs se despliega desde main. La misma línea de código llega a un sitio y no al otro.

Las tres salidas

QuéCoste
ALlevar los 142 commits a mainEs el release entero de 4 días de trabajo al worker de ingesta, no un cambio de ingesta
BUn release acotado: sólo warehouse-token.ts + ingest-client.ts sobre mainBarato y reversible, pero deja main y la rama divergiendo
CEnsayar primero en preview, donde la cara también está encendidaNo prueba el camino del worker, que es el que importa

Decisión del owner. Lo que NO se puede es apuntar la variable y ver qué pasa: el modo de fallo no aparece hasta que alguien conecta una fuente nueva.

⚠️ Y un cabo suelto para cuando se apunte: LAKEHOUSE_REST_WAREHOUSE=lakehouse seguiría siendo el respaldo del escritor para cualquier camino que no mande warehouse. La cara no lee lakehouse como inquilino ⇒ sin prefijo ⇒ 403 al crear. Hoy es inerte (ingest-client siempre manda uno y el fan-out está apagado), pero es exactamente la forma de mina que este doc acaba de desarmar en otro sitio.


⭐ I·2 — contestado EN CÓDIGO: el «nunca» está caducado (2026-08-13)

ratify.ts:126 dice, sobre las escrituras upsert:

«Un upsert no podrá marcarse nunca — es una limitación de pyiceberg, no un
pendiente»

Es cierto mientras firme pyiceberg. writer.py:556 hace table.upsert(...), que no acepta snapshot_properties. Pero por la cara firma la cara: updateTable lleva un add-snapshot en updates[] —cualquier commit de datos lo lleva—, que es justo lo que añadeSnapshot busca, y el operation_id sale de operacionEnVuelo, que encuentra la txn de ledger porque withNativeControlPlane la abre antes del data-plane.

⇒ Es la cuarta vez que un guardián con condición de retirada escrita caduca (regla 49).

⭐⭐ Y la mitad que nadie había nombrado

Que la cara firme no basta. ratify no llega a preguntar al catálogo: se para antes, en isAttributable(mode) (attribution-client.ts:116), que devuelve false para upsert por definición estática:

export function isAttributable(writeMode): boolean {
  return writeMode !== 'upsert';   // ⛔ una AFIRMACIÓN sobre pyiceberg, no una medición
}

I·2 son dos piezas, no una: que la cara firme (mecanismo, ya razonado) y retirar esa afirmación cuando se mida (guardián). Retirar sólo la segunda declararía verificable lo que no lo es; dejar sólo la primera seguiría diciendo unverifiable sobre commits que ya llevan su marca.

Gate de I·2: una escritura upsert por la cara cuyo snapshot lleve carbon.operation-id = el id de su txn de ledger, y ratify diga committed. ⛔ Exige I·1·b.


Historial

VersiónFecha
v1.12026-08-13I·0 contestada. Meter la ingesta por la cara no cuesta lo que parecía: el namespace legacy no bloquea (el prefijo lo hace irrelevante) y los grants están. Bloquea UNA cosa: las operaciones de ámbito NAMESPACE, que es donde NACE cada tabla. Y dos correcciones a la v1.0: el sink pg es inalcanzable, no una mina; y el riesgo real de dataset_rows está en ingest.stream, no ahí
v1.02026-08-12Recorrido cerrado: fuente → credencial y destino → esquema y tipos → transporte → commit y reconciliación. Siete hallazgos, y un patrón: casi todo lo roto es «declarado y no aplicado» o «traducido y no registrado» — no falta mecánica, falta que las decisiones dejen rastro
v0.12026-08-12Primera imagen. Destapa que la ingesta no pasa por la cara y que el sink pg es código muerto