Demoler el plano de datos de Postgres
Qué es esta spec. Retirar
dataset_rowsy su cortejo del repo y de la base. Nada más.Qué NO es. Implementar el plano de datos Iceberg + Parquet. Eso es otra spec (§7 lista lo que se le entrega). Aquí no se escribe ni una capacidad nueva sobre Iceberg: se borra la vieja, y donde la capacidad sólo existía sobre Postgres se cierra con un error honesto y se anota.
Index se queda en Postgres.
datasets,project_files,schemas,catalogs, las transacciones-como-intención: son gobernanza, no dato. No los toca nadie. Ver warehouse-index.md.
🏁 ESTADO (2026-07-31) — HECHO. El plano de datos de Postgres ya no existe.
| Paquete | Estado |
|---|---|
| Desacople (P·1 paginación · P·2 identidad · P·3 reserva de rangos) | ✅ |
| D·1 · que nadie LEA el plano | ✅ |
| D·2 · que nadie ESCRIBA el plano | ✅ |
| D·3 · las RPC dentro de Postgres | ✅ |
| D·4 · el cortejo de control + las 3 columnas de identidad | ✅ |
| D·5 · el DDL | ✅ DROP emitido (mig. 20261262) |
Postgres queda como Index: gobernanza. El dato vive en Iceberg + Parquet.
Verificado con el sistema desplegado, no sólo aplicado: facade-smoke.ts verde de punta a punta sin plano PG, sdk_healthz_strict() con 0 componentes no sanos, la ontología intacta (27.042 objetos) y 11.466 tests pasando — idéntico a antes del DROP.
El marcador — los tres trinquetes, a cero:
check:dataset-rows-seam 21 bypasses + 39 pineados → 0 + 18 ⭐
check:row-index-seam 47 ficheros · 185 usos → 0 ✅
check:dataset-births 1 fuera de la puerta → 1
⭐ Las cuatro lecciones, que valen más que la demolición
1 · Un trinquete a cero mide el REPO, no el SISTEMA. Los tres bajaron a cero y el plano seguía teniendo seis escritores — funciones PL/pgSQL, que no aparecen en ningún fichero. Es el punto ciego que ya obligó a escribir D·3, y volvió a aparecer en D·5.
2 · Una migración no es un cambio de la base: es un contrato entre el esquema y el código. Cuatro de las siete no podían aplicarse antes de desplegar el código que las acompaña —
read-routerconsultaba las tablas de flags en cada operación. El orden lo manda el despliegue, no el plan.3 · No basta con que el comando no falle: hay que mirar el resultado.
20261256se reportó aplicada sin hacer nada (DROP FUNCTION IF EXISTScon firma que no casa no falla, no hace nada), y tres RPC que escribían el plano sobrevivieron. Desde entonces las migraciones de esta línea se auto-verifican, y eso cazó dos errores más: unRETURNS TABLErenombrado y un regex que quitó 30 asserts en vez de 8.4 · Borrar código no lo caza el compilador — depende del lenguaje. En Python los nombres se resuelven en ejecución: al borrar la ruta del espejo se fue con ella un helper compartido, el módulo siguió importando,
/healthsiguió dando 200 y/lakehouse/ingestestuvo caído hasta que un e2e lo pisó. Un health-check dice que el proceso vive; sólo un e2e que ESCRIBA dice que el camino de escritura existe.Y un corolario que atraviesa las cuatro: §3 clasificó mal tres elementos (
iceberg_sync_log,iceberg_freshness(), y dos de las tres satélite). Siempre por lo mismo — se clasificó por dónde vivía la cosa, no por lo que hacía. Que es, exactamente, el error que creó el plano de datos de Postgres.
0 · Por qué se demuele — la cadena de evidencia, en corto
No se partió de una tesis, sino de una pregunta operativa —«¿por qué el SQL Editor no puede hacer INSERT?»— y la respuesta la dio el código, eslabón a eslabón:
no hay INSERT ← la puerta lo rechaza
← rompería la monotonía de __row_index
← y la extensión de DuckDB rehúsa escribir en tablas ORDENADAS
← y TODA tabla nativa declara SortOrder ASC sobre __row_index
← porque read_service.py LANZABA si se pedía lectura ordenada sin él
← porque así paginaba dataset_rows, la tabla POSTGRES a la que sustituye
El esquema era una copia columna a columna de la tabla Postgres (row_index→__row_index, id→__row_id, created_at→__created_at); se reimplementó sobre R2 un contador monótono que en PG es una secuencia (reserva distribuida de rangos de 2**40); y Postgres decidía si Iceberg se podía leer (iceberg_freshness() comparaba dos tablas de PG para autorizar una lectura del lakehouse).
La conclusión no es interpretativa: no era una tabla Iceberg con deuda, era una tabla Postgres cuyo almacenamiento se cambió a Parquet, conservando su esquema de identidad, su semántica de paginación, su contador monótono y su autoridad de lectura.
Nada de esto fue un error. Iceberg llegó como espejo, y la migración tenía que ser invisible para decenas de lectores ya escritos: el disfraz era la funcionalidad. El problema es que la factura sigue llegando cuando la razón —que hubiera datos que preservar— ya no existe.
1 · Los tres hechos que la dimensionan (medidos en la BD viva, 2026-07-30)
① No hay NADA que migrar. Cero.
Datasets con filas en dataset_rows | 73 |
De ésos, con espejo Iceberg succeeded | 73 |
| Datasets que viven SÓLO en Postgres | 0 |
| Filas que se perderían al soltar el plano | 0 |
Tamaño total de dataset_rows | 15 MB · 6.039 filas |
No hace falta migrar los ~100 datasets uno por uno. El espejo ya hizo ese trabajo. Soltar el plano PG no pierde un byte.
② 7 de los 10 escritores YA tienen brazo Iceberg
El techo de cada writer está declarado en lib/lakehouse/port-registry.ts:
maxMode: iceberg_native (7) | maxMode: pg (3) |
|---|---|
sync.full · sync.incremental · sync.append · pipeline.output · manual.table · ingest.api · cdc.append | row.edit · ingest.stream · dataset.manifest |
⇒ Para siete caminos, quitar Postgres es borrar la rama muerta de un if, no perder una capacidad. Sólo tres se cierran — y uno de ellos ni siquiera es dato (ver ③).
③ El dato no es el trabajo. El código sí.
DATOS a suprimir CÓDIGO a retirar
──────────────── ────────────────
6.039 filas · 15 MB 21 bypasses catalogados + 39 ficheros con conteo pineado
(todo lo demás: 0) ~20 funciones RPC dentro de Postgres
33 migraciones que la mencionan
2 · La regla que resuelve cada sitio
Todo fichero que hoy toca el plano PG cae en exactamente uno de tres veredictos. No hay un cuarto, y en particular no existe «portarlo a Iceberg» — eso es la otra spec.
| Veredicto | Cuándo | Qué se hace |
|---|---|---|
| 🗑️ BORRAR | El otro brazo (Iceberg) ya existe, o la capacidad ya se movió | Se borra la rama PG. Coste cero, riesgo cero. |
| 🏛️ MUDAR A INDEX | No es dato tabular: es control-plane que se alojó en dataset_rows porque era el único almacén de filas a mano | Se queda en Postgres, en su propia tabla. No es una regresión: es devolverle su sitio. |
| ⛔ CERRAR | La capacidad sólo existe sobre PG y no tiene brazo Iceberg | Fail-loud con un mensaje que nombra la causa, + una entrada en §7. Nunca un no-op silencioso. |
Por qué «cerrar» y no «dejarlo funcionando en PG»: porque mientras el camino PG exista, el plano no se puede soltar — y un camino que sigue vivo es un camino que sigue creciendo. El precedente ya está sentado: la edición de fila mentía (devolvía
success: truesin escribir nada sobre 14 datasets / 556.775 filas) precisamente porque se dejó apuntando a un plano que para ella ya no existía.
3 · La frontera: qué es dato y qué es Index
Lo que se demuele (plano de datos):
dataset_rows 6.039 filas ← el plano entero
dataset_branch_rows 0 filas ← ✅ SOLTADA (mig. 20261259); el Lazy-COW nunca se llenó
dataset_row_staging 0 filas
sdk_file_staging 0 filas
lakehouse_sync_partitions 0 filas ← ya sin sus columnas de identidad (mig. 20261255)
Lo que se queda en Postgres (Index — warehouse-index.md):
datasets · project_files · schemas · catalogs · objects · object_links
dataset_transactions (la mitad INTENCIÓN) · credentials
dataset_file_manifest ← NUEVA (mig. 20261258): el manifiesto de ficheros, que VOLVIÓ a Index
iceberg_sync_log ← se queda: hoy es el ledger de operaciones del JOP, no el log del espejo
Lo que se demuele con el plano porque es su andamiaje, no gobernanza:
iceberg_sync_cursor (1) ← ✅ soltado (mig. 20261257)
lakehouse_read_flags (19) · write_flags (47) · read_parity (20) ← ✅ soltados (mig. 20261257)
las 3 columnas de identidad (__row_index / __row_id / __created_at) ← ✅ a CERO
⚠️ Dos de esta lista resultaron NO ser andamiaje (ver §D·4): iceberg_sync_log cambió de significado —hoy es el ledger de operaciones del JOP, o sea Index— y iceberg_freshness() conserva un uso legítimo («¿aterrizó mi escritura?») distinto de su papel de autoridad. Los dos se quedan.
⚠️ La ontología NO se toca. 27.042 objetos sobreviven intactos: la migración 20261228 ya los desacopló (objects.base_properties denormaliza los datos de fila) y P·2 retiró el último puntero (objects.dataset_row_id).
4 · Lo ya hecho — el desacople (2026-07-30)
Tres paquetes, todos verdes, todos con su trinquete. Esto ya no es plan: es historia.
| Qué era | Resultado | |
|---|---|---|
| P·1 | La paginación era un atributo de la FILA (keyset sobre __row_index) | Pasa a ser una VENTANA del RESULTADO (offset/limit sobre un orderBy que declara la puerta, anclado a un snapshotId). Contrato de la puerta v1.2 → v1.3. El raise de read_service.py que exigía identidad para toda lectura ordenada ya no existe. → p1-pagination-of-the-result.md |
| P·3 | Reserva distribuida de rangos 2**40/partición | Borrada, + fan-out OFF por defecto. Sobrevive el conteo del init, renombrado start_offset → existing_rows, porque alimenta el expectedTotal anti-escritura-parcial. Migración 20261255. |
| P·2 | La identidad sintética como dirección de fila | Fuera de 8 ficheros. Retirado el puntero objects.dataset_row_id. Cerrado el no-op silencioso de la edición de fila (409 row_not_addressable). |
Trinquete check:row-index-seam: 47·185 → 0·0 tras D·4. En ci.yml, y ahora vigila que la identidad sintética no vuelva.
Tres premisas del plan original resultaron FALSAS al medirlas, y conviene recordarlo antes de creerse las que quedan: (a) P·3 «cae solo» — no caía, la reserva también daba la unicidad que consumían los lectores de P·2; (b)
kuzu-engine«es el caso duro, usa__row_indexcomo PRIMARY KEY» — su PK es un ordinal local, nunca fue__row_index; (c) «25 datasets sin clave + 14 sin esquema» — son 39 sin PK y 0 sin esquema, y ademásgraph_explorerno tiene ni una etiqueta de nodo configurada, así que no bloqueaban nada.
5 · Los paquetes que quedan
D·1 · Que nadie LEA el plano — ✅ HECHO (2026-07-30)
El trinquete pasó de 21 bypasses catalogados + 39 conteos pineados a 5 + 23. Lo que queda no son lectores: son los writes del pipeline (transformPaths + las 3 dataset-write-strategies, ya inalcanzables porque el router resuelve pipeline.output a Iceberg) y el manifiesto file-backed (applyDatasetSchema, que es el dataset.manifest pendiente de D·2). Por familia:
| Familia | Ficheros | Veredicto |
|---|---|---|
| espejo + canario | workers/iceberg/sync-worker.ts · sync-poller.ts · lakehouse/canary.ts · workers/lakehouse/canary-scheduler.ts · scripts/lakehouse-canary.ts | ✅ BORRADOS (D·1c) — el espejo copiaba PG→Iceberg y el canario comparaba los dos lados; sin PG no hay ni copia ni comparación. Desenchufados de start-workers.ts (imports, arranque, apagado, CONFIG) y del package.json |
| las dos primitivas de hidratación | pipelines/source-resolver.ts (temp) · lakehouse/sql-source-hydration.ts (inline) | ✅ HECHO (D·1a/b) — ambas pierden su rama PG. SqlSourcePlan colapsa de unión de 2 a un miembro |
| defaults source-respecting | dashboards/query · expectations/checks/_shared · pipelines/[id]/transforms/preview | ✅ HECHO (D·1b) — el literal era la rama PG de un if; desapareció con ella |
| ciclo de vida | datasets/delete-dataset.ts · projects/route.ts | 🗑️ borrar el borrado de filas (el DROP TABLE de Iceberg es de la otra spec) |
| SQL de grafos PG-native | graphs/ir-lowering-sql.ts · pg-executor.ts · pg-edge-resolver.ts | ✅ BORRADOS (D·1) — el grafo compilaba a SQL de Postgres sobre dataset_rows. Las rutas pg-single/pg-chain del ir-executor y la ruta explorer/expand se cierran (410 graph_expand_closed), sin degradar a Kuzu en silencio: el planificador eligió esa ruta por una razón. El guard puro isSafeColumn se mudó a query-types.ts |
| ciclo de vida | datasets/delete-dataset.ts · projects/route.ts | ✅ HECHO (D·1) — los 4 delete de filas se fueron; el resto de la cascada (transacciones, dataset, project_files) queda intacta. ⚠️ El DROP de la tabla Iceberg es de la otra spec: hasta entonces borrar un dataset deja su tabla huérfana en el catálogo |
| externo | services/kuzu-service/src/hydrator.ts | ✅ CERRADO — paquete aparte que leía el plano en crudo (no puede importar el seam). La salida para un paquete externo no es hablar SQL contra la base: es consumir la puerta por HTTP → §7 |
| compiladores de pipeline | compilers/{arrayPredicate/parser,split}.ts | ✅ eran sólo comentarios — reescritos, fuera de la lista |
| checkpoint efímero | workers/pipelines/checkpoint-ephemeral.ts | ✅ CERRADO el snapshot (copiaba filas dentro del plano; sobre Iceberg el equivalente es una branch/clone de tabla → §7). Los dos DELETE del reaper: 🗑️ borrados |
| reconciliación de esquema | workers/pipelines/schema-reconcile.ts | ✅ NO-OP 🗑️ — podaba del blob JSONB las claves fuera de esquema. Es un problema intrínseco a guardar la fila como blob; un formato columnar no lo tiene. No es capacidad que reconstruir, es artefacto de la forma retirada |
| apply-schema file-backed | projects/applyDatasetSchema.ts | ✅ CERRADO el write (materializaba un snapshot en el plano). Se cerró entero, incluido el update de datasets: escribir schema/row_count sin haber escrito filas sería anunciar una tabla que no existe. Queda la LECTURA del manifiesto → dataset.manifest, D·2 |
| write del pipeline | pipelines/runtime.ts | ✅ SQL muerto borrado — el INSERT ya era inalcanzable (D·2 hace que el router lance antes) |
Más 39 ficheros con conteo pineado que el trinquete ya vigila.
D·2 · Que nadie ESCRIBA el plano — ✅ HECHO (2026-07-30)
El cambio de fondo, en una línea: el substrato dejó de ELEGIRSE y pasó a decidirlo la CAPACIDAD del puerto. lib/lakehouse/write-router.ts ya no lee lakehouse_write_flags ni aplica clamp: maxMode es el destino.
| Writer | Techo | Veredicto |
|---|---|---|
sync.full · sync.incremental · sync.append · pipeline.output · manual.table · ingest.api · cdc.append | iceberg_native | ✅ siempre iceberg. Y con ello los 40 pins por dataset dejaron de significar nada, sin tocarlos — exactamente como se predijo |
ingest.stream | pg | ✅ CERRADO — 410 ingest_stream_closed. El protocolo open/write/commit no tiene equivalente (un append por lote = un snapshot por lote); necesita staging-append → §7 |
row.edit | pg | ✅ ya cerrado en P·2 (409 row_not_addressable) |
dataset.manifest | pg | ✅ MUDADO A INDEX (mig. 20261258) — dataset_file_manifest, con lib/datasets/file-manifest.ts como puerta única. No se fue a Iceberg: volvió a Index, que es donde le tocaba |
🏛️ El manifiesto de ficheros — el único caso de «mudar», y por qué no era dato
Un dataset file-backed (media set, unstructured) no tiene filas: tiene ficheros, y de cada uno guardaba un puntero a sus bytes en Storage. Vivían en
dataset_rowsporque era el único almacén de filas a mano — y por eso parecían dato tabular. El propio registry ya lo desmentía: «manifests are control-plane file pointers, not tabular data».Hay DOS vocabularios y no se han unificado: un media set habla de
{media_reference, media_item_rid, timestamp, path}; la SDK, de{filename, size_bytes, mime_type, storage_path, uploaded_at}. La tabla tipa lo común (posición, clave del fichero, ruta, tamaño, mime) y conserva la forma completa enpayload, así que ningún consumidor cambia de vocabulario: la galería, el pipeline de media sets, la SDK y el apply-schema ven exactamente lo que veían.Y hay un detalle que lo justifica solo: el manifiesto siempre se direccionó por la identidad REAL del fichero —
media_item_ridofilename—, nunca por la identidad sintética que P·2 retiró. Nunca la necesitó.Repuntados:
fileBackedDatasetUpload·notebooks/[id]/datasets/upload·projects/media-set/item·sdk/v1/…/files(listado) ·sdk/v1/…/files/upload·applyDatasetSchema· y el browse, que para un datasetcontent_type='file'sirve el manifiesto en vez de preguntarle a la puerta por filas que no existen.
Consecuencias asumidas y queridas: un call-site de un puerto capaz que sólo traiga brazo Postgres lanza (lib/pipelines/runtime.ts:414, el write de output del runtime de pipelines). No se le puso brazo: los builds de pipeline se rompen a propósito, porque viven sobre el plano que se demuele y vuelven cuando aterrice la spec de Iceberg.
Trinquete: dataset-rows-seam 19 → 18 bypasses, 37 → 36 conteos; expectations/_shared salió de la lista por completo.
⛔ NO SE FLIPEA NADA. Los flags son andamiaje, no herramienta
Hubo una propuesta —mía— de «flipear los 7 escritores a
iceberg_native» como primer paso. Es un error de encuadre y queda anulada. Flipear un flag es elegir substrato: tiene sentido en una migración gradual y reversible. Esta spec no migra, demuele, y el final tiene un solo substrato — así que no hay nada que elegir.lakehouse_read_flags/write_flags/read_parityestán en la lista de §3: se borran con el plano, junto con elpickPortModeque los lee.El corolario práctico: los pins por dataset no se quitan, se quedan irrelevantes. En cuanto D·1/D·2 borren la rama PG del código, nadie consulta la tabla y da igual lo que diga. Ningún canario, ningún rollout, ninguna ventana de observación.
Se conserva aquí el censo del pre-vuelo porque describe la superficie a borrar — no porque haya que operarla. Medido en el control-plane de producción el 2026-07-30 (
lakehouse_write_flags, 47 filas):
Escritor Estado real sync.full·sync.incremental·sync.append·cdc.appendYA tienen fila GLOBAL iceberg_native(desde 2026-06-30). No hay nada que flipearpipeline.output·manual.table·ingest.api·sql-editor.dmlTecho iceberg_native, pero sin fila global ⇒ caen adefaultMode: 'pg'del registryLo que hoy mantiene vivo el plano PG son 40 filas de pin por dataset (4 escritores × 10 datasets), todas con la misma nota:
F4 pipeline-input pin (sourceCte reads dataset_rows; deferred to Karma)— puestas el 2026-06-30 por_pin-tmp.El dato útil que sale de ahí no es operativo, es de INVENTARIO: el pin señala con el dedo el sitio de código que hay que borrar.
lib/pipelines/source-resolver.tsya está rutado por la facade y ya tiene la rama de hidratación escrita — lo que hace que siga leyendo Postgres es que su rama PG existe y es el defecto. Borrar esa rama deja una sola, y el pin deja de significar nada.Lo mismo con
pipeline.source, que no tiene NINGUNA fila enlakehouse_read_flags(10 readers la tienen; él no): no hay que darle una, hay que quitarle la alternativa.
D·3 · Las RPC dentro de Postgres — ✅ HECHO (2026-07-30)
Es el último sitio donde el plano se escribe sin que el trinquete se entere: viven dentro de Postgres, así que ningún fichero del repo las delata.
✅ Soltadas (migración 20261256): sdk_append_dataset_rows · sdk_commit_dataset_transaction(_v2) · sdk_abort_dataset_transaction · sdk_open_branch_transaction · sdk_commit_branch_transaction · reap_archived_branch_overlays. En Node, la superficie transaccional de la SDK v1 (open-branch / append / commit / abort) responde 410. sdk_commit_file_batch / sdk_abort_file_batch NO se tocan: son lotes de FICHERO, o sea dataset.manifest (🏛️ Index).
⚠️ Corrección: branches y PRs NO son una feature del plano de datos
El plan decía «las branches/PRs se retiran». Falso, y por poco no se borra una feature viva. Medido: de las RPC de branch, sólo 5 tocan el plano (
merge_pull_request,compute_pr_diff,sdk_open/commit_branch_transaction,create_workspace_branch). Las demás —open/close/reopen_pull_request,archive_workspace_branch,reap_old_closed_prs— versionan celdas de notebook, con UI viva en el tab de Jupyter (BranchSelector,CreateBranchModal,PullRequestsView, el diff de celdas…). Se quedan.Lo que sí se retira es el overlay de FILAS por branch (
dataset_branch_rows, 0 filas — el Lazy-COW nunca se llenó).
✅ Las funciones mixtas, resueltas (migración 20261259) — y sólo eran dos:
| Función | Qué se le quitó |
|---|---|
create_workspace_branch | Nada: estaba limpia. Su única mención al overlay era un comentario de archive_workspace_branch |
compute_pr_diff | Todo su cuerpo. Su RETURNS TABLE era enteramente del plano (contaba filas del overlay de cada rama contra dataset_rows). Conserva la FIRMA y la validación —pedir el diff de un PR inexistente sigue siendo un error— y devuelve conjunto vacío: el endpoint y su UI siguen vivos, y «este PR no toca ningún dataset» es literalmente cierto ahora |
merge_pull_request | El paso 3 de seis. Hacía: (1) bloquear el PR, (2) bloquear y validar ambas ramas, (3) volcar las filas del overlay de head al plano o al overlay de base, (4) mover el head pointer de base, (5) marcar head merged, (6) marcar el PR merged. Sólo el 3 era del plano. Mergear sigue cambiando el estado de rama y PR; lo que ya no hace es mover filas |
Y con eso se soltó dataset_branch_rows (0 filas). También cayeron sus últimos consumidores en código: el conteo de overlay en lib/branches/queries.ts y en sdk/v1/…/schema, y —el más profundo— la resolución de overlay de junction.ts: PgSource era una unión (dataset_rows | dataset_branch_rows) y ahora tiene un solo miembro. Direccionar un branchId sigue siendo válido y sigue validándose (una rama archivada no es legible), pero resuelve siempre a la base.
D·4 · El cortejo de control — ✅ HECHO (2026-07-30)
El seam de LECTURA dejó de elegir, igual que el de escritura en D·2: read-router ya no consulta lakehouse_read_flags ni el oráculo. Con ello se van el modo por puerto (off/shadow/iceberg/iceberg_only), el gate de frescura y el fallback transparente a Postgres — caerse a PG era correcto mientras hubiera un PG con los mismos datos; ahora serviría vacío, que es peor que fallar.
✅ Soltadas (migración 20261257): lakehouse_read_flags · lakehouse_write_flags · lakehouse_read_parity · iceberg_sync_cursor. Ningún código las leía ya. Borrados también lib/lakehouse/parity.ts (+test) y las tres CLIs de operador de la migración — lakehouse:flags, lakehouse:write-flags, lakehouse:cutover —, que gobernaban un proceso terminado.
⚠️ Dos cosas del §3 que resultaron NO ser del plano
iceberg_sync_logCAMBIÓ DE SIGNIFICADO y se queda. Nació como el log del espejo PG→Iceberg —y §0·6 lo señala como parte del disfraz—, pero hoy es el ledger de operaciones del JOP:ratifylo escribe y lo repara para que una escritura nativa sea atribuible, yreconcile/compensatelo leen. Eso es gobernanza: es Index. Merece un rename; renombrar no es demoler.
iceberg_freshness()pierde su papel de AUTORIDAD pero conserva un uso legítimo. Comparar dos tablas de Postgres para autorizar una lectura del lakehouse —el eslabón más grave de §0— ha desaparecido con la elección de substrato. Pero la misma función responde también «¿aterrizó mi escritura?» (dataset-output-materialiser), que no es autorización sino observación. Se queda por eso.
✅ Las tres columnas de identidad — CERO (2026-07-30)
__row_index·__row_id·__created_atya no aparecen en ninguna línea de código del repo. El trinquetecheck:row-index-seampasa de vigilar una retirada a vigilar que no vuelva.Recorrido: 47·185 (contando prosa) → 28·94 al recalibrar a sólo-código → 22·75 tras P·2 → 0·0.
Y la línea que más pesaba de todas:
_create_native_tabledeclarabaSortOrder ASCsobre__row_index. Ésa es la punta de la cadena de §0 —la extensión Iceberg de DuckDB rehúsa escribir en tablas ORDENADAS → toda tabla nativa declaraba SortOrder → INSERT y UPDATE eran imposibles → la puerta los rechazaba—. Sin identidad que ordenar no hay orden que declarar: las tablas se crean sin SortOrder.⇒ El motivo por el que 6 de los 10 verbos estaban cerrados ha desaparecido. El rechazo sigue en pie (no es esta spec quien abre verbos), pero su mensaje ya lo dice y la matriz está pendiente de RE-MEDIR sobre una tabla creada sin la declaración. Es la primera entrega concreta que esta demolición le hace a la spec del plano Iceberg.
Se fue con ellas: el estampado por fila (
_stamp_row), la continuación del contador en el writer chunked,withIdentityFields,RESERVED_*,includeIdentityen todo el contrato de la puerta, el gate «identidad ausente → degrada a PG»,FreshnessVerdict.identityReadyeicebergIdentityReady. Y el espejo en Python (sync_service.py+ la rutaPOST /lakehouse/sync), que sobrevivía al borrado de su worker en D·1c.
D·5 · El DDL — ⬅ EN CURSO · paso 0 HECHO · el DROP está VETADO
✅ Paso 0 — el SQL muerto (2026-07-31)
lib/pipelines/transformPaths.ts y las tres dataset-write-strategies seguían construyendo SQL contra dataset_rows. Era inalcanzable desde D·2 —el router resuelve pipeline.output a Iceberg—, pero SQL en texto: el DROP no lo rompe en compilación, lo convierte en un error de runtime el día que algo lo alcance.
Borrado. Trinquete check:dataset-rows-seam: 4 bypasses → 0. La ALLOWLIST está vacía y, como le pasó a check:row-index-seam, cambia de oficio: ya no vigila una retirada, vigila que el plano no vuelva.
Lo que se llevó por delante merece nombrarse, porque es la tesis de la spec en pequeño: al caer apply(), una write-strategy dejó de ser una sentencia y pasó a ser un cómputo. Lo que la sentencia hacía ADEMÁS de computar no se perdió — cambió de lado: preservar y acumular son hoy del motor (nativeReplayMode: 'upsert' | 'append', que ml-runner ejecuta server-side), y el anti-join de append_new lo hace Node leyendo los PK del target por la puerta. Con apply se fueron también el PoolClient, el targetDatasetId y el transactionId del WriteContext: una estrategia ya no habla con la base. Sus pruebas de comportamiento contra pglite se borraron con el SQL que probaban, y los unitarios se reapuntaron al cómputo — que no tenía ni una prueba.
⛔ El DROP está VETADO — y no por el dato
scripts/dataspaces/d5-preflight-census.ts (sólo-lectura) es la comprobación previa. El dato da vía libre y el DROP sigue sin poder darse, que es justo la lección: el censo tiene dos mitades, y la que veta es la segunda.
Medido contra el control-plane vivo el 2026-07-31:
| ① filas · datasets · tamaño | 6.039 · 73 · 15 MB — idéntico a §1·① |
② con espejo succeeded | 73/73 · datasets sólo en PG: 0 · filas que se perderían: 0 |
| ③ la ventana D·1→D·2 | ninguna fila posterior a su último espejo |
| ④ las satélite | las tres a 0 filas |
Y entonces la segunda mitad:
1 · Seis migraciones SIN APLICAR — de las que 2 ya se aplicaron el 2026-07-31 (ver abajo) y 4 siguen bloqueadas. El control-plane no lleva tracker, así que esto no salta solo — ver controlplane-db-drift.md.
✅ Aplicadas a producción (2026-07-31):
20261228+20261258
20261228era la que PROTEGE el dato, no la que lo arriesga, y eso se vio al medir: los 5.193 objetos con puntero teníanproperties = '{}', los 5.193. Toda su información vivía únicamente endr.data— o sea, sólo en el plano PG. Sin este backfill, elDROPlos habría dejado vacíos.Verificado tras aplicarla: backfill 5.193/5.193, divergencia 0 entre
base_propertiesydr.data(la vista sirve exactamente lo mismo que antes: cambio invisible), 0 vistas dependen ya dedataset_rows, y las dosfn_instantiate_links_*fuera de ⑦.20261258es aditiva pura (CREATE TABLE), segura con cualquier versión del código.(De paso: el detector de ⑦ contaba comentarios. Una función recién migrada conserva un
-- … no dataset_rows readexplicando el cambio y quedaba marcada para siempre. Ahora descuenta los comentarios de línea antes de buscar.)
⛔ Las 4 restantes están acopladas al DESPLIEGUE, no a una decisión
mainsólo tiene commits de docs desde D·1: todo el código de D·1–D·4 está sin commitear, así que producción corre el código pre-demolición. Contra ese código, las cuatro no son «pendientes», son roturas inmediatas — verificado enmain, no supuesto:
Migración Qué suelta Qué lo usa en main20261257lakehouse_read_flags·write_flags·read_parity·iceberg_sync_cursorread-router.ts:79ywrite-router.ts:89las consultan víamakeFlagLoaderen cada lectura y cada escritura del seam. Soltarlas tumba producción en el acto20261259dataset_branch_rows8 sitios, incluidos junction.ts(la puerta) ylib/branches/queries.ts20261256las RPC transaccionales de la SDK 3 rutas vivas de sdk/v1/…/transactionslas llaman (aún no devuelven 410)202612552 columnas de lakehouse_sync_partitionsel fan-out del sync-worker, que en mainsigue existiendo (D·1c lo borró sólo en el árbol)Van con el despliegue de D·1–D·4, no antes. El orden es: commitear + desplegar el código → aplicar las cuatro → re-correr el censo.
⚠️ Corrección: «La ontología NO se toca» es FALSO en la BD viva
§3 lo daba por hecho: «la migración
20261228ya los desacopló (objects.base_propertiesdenormaliza los datos de fila)». Esa migración existe en el repo y no está aplicada:objects.base_propertiesno existe.Hoy, en la BD viva, la ontología lee el plano en tres sitios: la vista
objects_resolved(LEFT JOIN dataset_rows dr ON dr.id = o.dataset_row_id) y las dos funciones de auto-link, que el audit de drift reporta divergiendo — el repo dicebase_properties, la base dicedr.data.Y no es cosmético: 5.193 objetos llevan puntero, 0 rotos, y los 5.193 DIVERGEN de
o.properties. La vista sirve hoy datos que sólo existen en el plano PG — yobjects_resolvedes superficie viva de producto (el grid de objetos, los links, el mapa/geo, el twin).El orden es duro, no una preferencia: el paso 1 de
20261228es un backfill que LEEdataset_rows. Si elDROPva primero, ese backfill deja de ser posible y esos 5.193 objetos pierden sus propiedades resueltas para siempre.
2 · Cuatro funciones dentro de Postgres que D·3 no catalogó — el mismo punto ciego que obligó a escribir D·3: una función PL/pgSQL no aparece en ningún fichero del repo, así que el trinquete puede estar a CERO y el plano seguir teniendo escritores.
| Función | Qué hace | Nota |
|---|---|---|
sdk_commit_file_batch | ESCRIBE (INSERT INTO dataset_rows) | Viva: la llama sdk/v1/…/files/batches/[batchId]/commit. D·3 la excluyó a propósito —«son lotes de FICHERO, o sea dataset.manifest (🏛️ Index)»—. La clasificación es correcta; la consecuencia no se sacó: escribe el manifiesto EN el plano. Había que mudarle el destino, no dejarla |
reap_orphan_checkpoint_datasets | ESCRIBE (DELETE FROM dataset_rows) | D·1 borró los dos DELETE del reaper en TypeScript; éste es otro reaper, dentro de Postgres |
fn_sync_objects_after_ingest | lee (×4) | Sincroniza objetos tras ingesta |
sdk_healthz_strict | lee | Sólo comprueba una constraint sobre dataset_rows; el DROP la rompe |
3 · La mudanza del manifiesto está a medias. 20261258 crea la tabla vacía y no arrastra lo que ya había: hay 13 datasets file-backed con 11 punteros que sólo viven en el plano. Y —esto es lo importante— ② no lo canta, porque esas filas SÍ tienen espejo Iceberg. Un manifiesto espejado a Parquet no es un manifiesto en Index: el criterio «tiene espejo» no aplica a lo que nunca fue dato.
4 · Lo que quedaría huérfano, a resolver explícitamente y nunca con DROP … CASCADE —que borraría en silencio una vista que sirve producto—: la FK objects.objects_dataset_row_id_fkey (la columna dataset_row_id puede quedarse: es procedencia, o sea Index; lo que cae es la FK), la vista objects_resolved (que 20261228 reescribe sin el join) y la policy RLS dataset_rows_workspace.
El orden que salió de ahí — todo hecho salvo el DROP
✅ 1. aplicar 20261228 + 20261258 ← el backfill LEE el plano: iba antes o no iba
✅ 2. COMMITEAR Y DESPLEGAR D·1–D·4 ← el bloqueante real: main aún corría el código viejo
✅ 3. aplicar 20261255 · 56 · 57 · 59 (+ 20261260, la correctiva)
✅ 4. mudar sdk_commit_file_batch → dataset_file_manifest + backfillear los 11 punteros
✅ 5. cerrar reap_orphan_checkpoint_datasets · fn_sync_objects_after_ingest · sdk_healthz_strict
✅ 6. soltar la FK objects.objects_dataset_row_id_fkey (la COLUMNA se queda: es procedencia)
⬅ 7. DROP TABLE dataset_rows + las tres satélite ← 33 migraciones la mencionan
El paso 2 es el que gobernó todo lo demás, y no estaba en el plan original porque el plan sólo miraba el repo. Cuatro de las seis migraciones no se podían aplicar antes de desplegar el código que las acompaña: en un sistema donde el esquema y el código se despliegan por separado, una migración no es un cambio de la base, es un contrato entre las dos mitades.
✅ EL CENSO DA VÍA LIBRE (2026-07-31)
Migración 20261261 — un veredicto por función, con la regla de §2:
| Función | Veredicto | |
|---|---|---|
sdk_commit_file_batch | 🏛️ MUDADA A INDEX | Escribe el manifiesto en dataset_file_manifest. La mudanza SIMPLIFICA: el original hacía un baile de row_index (reusar el existente, max+rn para los nuevos) sólo porque en dataset_rows la fila se direccionaba por su índice. El manifiesto nunca lo necesitó — siempre se direccionó por la identidad REAL del fichero, y la tabla nueva lo declara (UNIQUE (dataset_id, item_key)) |
| Los 11 punteros | 🏛️ BACKFILLEADOS | La última lectura del plano de esta línea de trabajo. Cubiertos 11/11 |
reap_orphan_checkpoint_datasets | 🗑️ rama PG borrada | El borrado del dataset (Index) se queda; rows_freed pasa a ser 0 honesto |
fn_sync_objects_after_ingest | 🗑️ BORRADA | No se cierra fail-loud porque no la llamaba nadie — ni Node, ni Python, ni otra función, ni un trigger. Cerrar es para una capacidad viva; ésta era un resto |
sdk_healthz_strict | ✂️ 8 asserts retirados | Comprobaba la EXISTENCIA de siete cosas que esta spec borró a propósito ⇒ reportaba el sistema enfermo por estar sano. Y el octavo era peor: resolvía 'public.dataset_rows'::regclass, que lanza en cuanto la tabla no exista — el healthz se habría caído entero con el DROP |
objects_dataset_row_id_fkey | ⛔ soltada | La COLUMNA se queda: es procedencia, y la procedencia es Index. Lo que cae es la restricción que ataba Index al plano |
Verificado en vivo, no sólo aplicado: sdk_healthz_strict() ejecuta y devuelve 30 componentes, 0 no sanos; el reaper ejecuta y devuelve rows_freed: 0; la función muerta ya no está; la columna sigue y su FK no.
⑤ huérfanos nada
⑦ funciones ninguna ← ninguna función de Postgres toca ya el plano
③bis manifiesto 11/11 cubiertos
⑥ migraciones las 7 ✅
✅ VÍA LIBRE
🏁 EL DDL — EMITIDO (2026-07-31)
Migración 20261262. DROP TABLE public.dataset_rows — sin CASCADE: si algo hubiera dependido todavía, queríamos que fallara en vez de arrastrarlo en silencio. Que no hiciera falta CASCADE es, precisamente, el resultado de D·1..D·5.
El pre-vuelo se repite dentro de la misma transacción que el borrado (funciones, FK, vistas, cobertura del manifiesto): el censo corre desde fuera y puede quedarse rancio entre su medición y el DROP. Es barato, y lo que viene detrás es irreversible.
`dataset_rows` no existe: el plano ya está demolido. Nada que medir.
Verificado tras soltarlo: facade-smoke.ts verde de punta a punta sin plano PG (write nativo → read/aggregate/schemaOnly → paginación por offset → gobernanza propagada) · sdk_healthz_strict() da 30 componentes, 0 no sanos · la ontología intacta: 27.042 objetos, los 5.193 con sus base_properties y su procedencia conservada, y objects_resolved sirviendo 21.918 objetos con contenido · suite JS 11.466 pasan, idéntico a antes del DROP.
⚠️ Y una tercera corrección a §3: de las tres satélite, dos NO eran andamiaje
§3 las listaba como «se demuelen con el plano porque son su andamiaje». Medido antes de soltarlas —estar VACÍA no es estar SIN USAR, y el censo sólo contaba filas—:
dataset_row_staging✅ soltada. Era el aterrizaje del protocolo transaccional de la SDK, que está cerrado sdk_file_staging⛔ SE QUEDA. VIVA: la ruta de upload de batches escribe en ella y sdk_commit_file_batchla lee y la vacía. Es exactamente lo mismo que el manifiesto — control-plane, o sea 🏛️ Indexlakehouse_sync_partitions⛔ SE QUEDA. VIVA: la usa base-polling-worker. Nació para el fan-out del espejo, pero el espejo se borró y ella siguió sirviendo al pollingVan ya tres veces que un elemento de esa lista resulta no serlo (antes:
iceberg_sync_log, que había cambiado de significado, eiceberg_freshness(), que conservaba un uso legítimo). El patrón es siempre el mismo: se clasificó por dónde vivía la cosa, no por lo que hacía.De paso salió un daño evitado: la ruta de commit de la SDK cerraba al FINAL, tras ~110 líneas de trabajo que incluían leer
dataset_row_staging. Soltar la tabla habría convertido su 410 honesto en un 500 opaco. El cierre se movió arriba y el cuerpo muerto se suprimió — con sus imports y helpers huérfanos, que es el mismo resto que en Python dejó/lakehouse/ingestcaído.
Dos trampas que costaron un intento cada una, y las dos las cazó la auto-verificación
CREATE OR REPLACEno puede renombrar una columna delRETURNS TABLE. La firma viva erareaped_datasets, noreaped; renombrarla cuenta como cambio de tipo de retorno. Falló en voz alta y, al ir todo en una transacción implícita, no se aplicó nada a medias.En una ARE de PostgreSQL, si el PRIMER cuantificador es greedy, TODA la expresión lo es — el
.*?deja de ser lazy y no se comporta como PCRE. El bloque que reescribíasdk_healthz_strictpor regex desde dentro de la base quitó 30 asserts en vez de 8; lo cantó el propio conteo de la migración, que abortó antes de aplicar. La sustitución se hizo fuera, se contó (37 → 29, cero colaterales) y en la migración va el resultado literal: determinista y revisable en el diff.Es la misma lección otra vez, y ya van varias en esta spec: no basta con que el comando no falle — hay que mirar el resultado.
6 · El orden, y el marcador
| Paquete | Qué desbloquea | |
|---|---|---|
| 1 | ✅ D·1 — los bypasses de lectura (21 → 4, y los 4 no son lectores) | Que dataset_rows no tenga lectores |
| 2 | ✅ D·2 — el substrato lo decide la capacidad · ingest.stream cerrado · dataset.manifest mudado a Index | Que no tenga escritores |
| 3 | ✅ D·3 — las RPC + SDK transaccional + las funciones mixtas de branch (dataset_branch_rows soltada) | Que no tenga escritores dentro de Postgres |
| 4 | ✅ D·4 — el cortejo de control + las 3 columnas de identidad a CERO | Que nadie tenga que ELEGIR substrato |
| 5 | ⬅ D·5 — paso 0 ✅ (SQL muerto · trinquete a 0) · el DDL VETADO por el censo | — |
No hay paso 0 en el código: cada paquete D·1–D·4 es borrado, y el estado de los flags no condiciona el orden porque los flags mueren en D·4 sin haberse tocado antes. Sí lo hay en la BASE, y no se vio hasta medirla: las migraciones que estos paquetes dejaron escritas hay que aplicarlas, y 20261228 va antes que todo porque su backfill es la última lectura del plano.
El marcador honesto de ESTA spec no es la matriz de verbos (ésa mide el plano Iceberg, y es de la otra). Aquí el marcador son los tres trinquetes, y ya están abajo:
check:dataset-rows-seam 21 bypasses + 39 pineados → 0 + 18 ⭐ ALLOWLIST vacía
check:row-index-seam 47 ficheros · 185 usos → 0 ✅
check:dataset-births 1 fuera de la puerta → 1
…con la advertencia que esta spec se ha ganado a pulso dos veces: los tres miden el REPO. Lo que mide el sistema es el censo de pre-vuelo, y hoy veta.
7 · Lo que hereda la otra spec
Lo que aquí se cierra no desaparece del producto para siempre: se apunta. Ésta es la entrada, explícita, de la spec del plano Iceberg + Parquet:
| Capacidad | Estado hoy | Lo que necesita |
|---|---|---|
| Editar / borrar una fila | ⛔ cerrada (409 row_not_addressable) | DELETE/MERGE por la puerta direccionando por PK. Son los 2 verbos que Junction YA permite — el motivo del registry («point DELETE has NO Iceberg primitive») está rancio: DuckDB los hace, medido el 2026-07-29 en scripts/duckdb/verb-conformance.ts |
ingest.stream (write_rows transaccional) | ⛔ cerrada | Un diseño de staging-append: bufferizar lotes → un snapshot al commit. open/write/commit no mapea a Iceberg tal cual |
| El grafo | ⛔ cerrado | Decidir sobre qué motor compila (DuckDB, o dejar de compilar a SQL) |
kuzu-service hydrator | ⛔ cerrado | Es un paquete externo: necesita leer por HTTP contra la puerta, no por SQL |
| Fan-out de sync paralelo | apagado (P·3) | La concurrencia la da Iceberg; ya no un contador nuestro |
| Branches / PRs de dataset | retiradas (D·3) | Rediseñar sobre branches de Iceberg, si se quieren |
| Build DELTA / incremental de pipeline | ⛔ cerrado (D·1) | Su ventana era del ledger PG (transaction_id + committed_at); una fuente hidratada no la arrastra. Hay que decidir qué es un incremental sobre snapshots |
| Apply-schema de un dataset file-backed | ⛔ cerrado (D·1) | Es una INGESTA: su sitio es ingest.api, que ya escribe Iceberg. Sólo hay que cablearla |
| Snapshot de checkpoint de pipeline | ⛔ cerrado (D·1) | Copiaba filas dentro del plano; el equivalente nativo es una branch/clone de tabla Iceberg |
| Write de output del runtime de pipelines | ⛔ lanza (D·2) | lib/pipelines/runtime.ts:414 no trae brazo nativo. El helper YA existe (native-output-write.ts, el que usa transformPaths): es cablear un call-site |
| Los 39 datasets sin PK | sin dirección de fila | Decisión de producto: declarar clave, o aceptar que sus filas no son direccionables |
⭐ Y lo que esta demolición le ENTREGA, no sólo le pide
_create_native_tableya no declaraSortOrdersobre ninguna columna. Ésa era la punta de la cadena del §0 —DuckDB rehúsa escribir en tablas ordenadas → toda tabla nativa declaraba SortOrder → INSERT y UPDATE imposibles—. El motivo por el que 6 de los 10 verbos estaban cerrados ha desaparecido.El rechazo de la puerta sigue en pie (abrir verbos no es esta spec) pero su mensaje ya lo dice. Primera tarea recomendada de la spec Iceberg: re-medir la matriz de verbos con
scripts/duckdb/verb-conformance.tssobre una tabla creada sin la declaración. Marcador de referencia: 4/10 antes de todo esto.
8 · Lo que NO se puede medir desde el repo
- Si la SDK v1 tiene consumidores externos. Sus RPC transaccionales tienen 5 filas de uso, pero si hay clientes fuera, el contrato es un compromiso. Es la única pregunta abierta que puede cambiar D·3.
- Si los 5 branches / 1 PR representan trabajo de un usuario o son pruebas. (Sin filas detrás en cualquier caso.)
- Latencia. Nada medido, ni de lo viejo ni de lo nuevo.
Cruza con: warehouse-as-narrow-waist.md (la tesis que el disfraz rompía) · warehouse-index.md (lo que se queda en PG) · p1-pagination-of-the-result.md (el desacople de la paginación) · warehouse-writes-parity.md (el marcador de la OTRA spec) · junction.md (la puerta).