Published

Demoler el plano de datos de Postgres

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

Demoler el plano de datos de Postgres

Qué es esta spec. Retirar dataset_rows y 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.

PaqueteEstado
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 DDLDROP 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-router consultaba 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. 20261256 se reportó aplicada sin hacer nada (DROP FUNCTION IF EXISTS con 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: un RETURNS TABLE renombrado 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, /health siguió dando 200 y /lakehouse/ingest estuvo 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_rows73
De ésos, con espejo Iceberg succeeded73
Datasets que viven SÓLO en Postgres0
Filas que se perderían al soltar el plano0
Tamaño total de dataset_rows15 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.appendrow.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.

VeredictoCuándoQué se hace
🗑️ BORRAREl otro brazo (Iceberg) ya existe, o la capacidad ya se movióSe borra la rama PG. Coste cero, riesgo cero.
🏛️ MUDAR A INDEXNo es dato tabular: es control-plane que se alojó en dataset_rows porque era el único almacén de filas a manoSe queda en Postgres, en su propia tabla. No es una regresión: es devolverle su sitio.
CERRARLa capacidad sólo existe sobre PG y no tiene brazo IcebergFail-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: true sin 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é eraResultado
P·1La 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·3Reserva distribuida de rangos 2**40/particiónBorrada, + fan-out OFF por defecto. Sobrevive el conteo del init, renombrado start_offsetexisting_rows, porque alimenta el expectedTotal anti-escritura-parcial. Migración 20261255.
P·2La identidad sintética como dirección de filaFuera 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_index como 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ás graph_explorer no 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:

FamiliaFicherosVeredicto
espejo + canarioworkers/iceberg/sync-worker.ts · sync-poller.ts · lakehouse/canary.ts · workers/lakehouse/canary-scheduler.ts · scripts/lakehouse-canary.tsBORRADOS (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ónpipelines/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-respectingdashboards/query · expectations/checks/_shared · pipelines/[id]/transforms/previewHECHO (D·1b) — el literal era la rama PG de un if; desapareció con ella
ciclo de vidadatasets/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-nativegraphs/ir-lowering-sql.ts · pg-executor.ts · pg-edge-resolver.tsBORRADOS (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 vidadatasets/delete-dataset.ts · projects/route.tsHECHO (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
externoservices/kuzu-service/src/hydrator.tsCERRADO — 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 pipelinecompilers/{arrayPredicate/parser,split}.tseran sólo comentarios — reescritos, fuera de la lista
checkpoint efímeroworkers/pipelines/checkpoint-ephemeral.tsCERRADO 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 esquemaworkers/pipelines/schema-reconcile.tsNO-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-backedprojects/applyDatasetSchema.tsCERRADO 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 pipelinepipelines/runtime.tsSQL 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.

WriterTechoVeredicto
sync.full · sync.incremental · sync.append · pipeline.output · manual.table · ingest.api · cdc.appendiceberg_nativesiempre iceberg. Y con ello los 40 pins por dataset dejaron de significar nada, sin tocarlos — exactamente como se predijo
ingest.streampgCERRADO410 ingest_stream_closed. El protocolo open/write/commit no tiene equivalente (un append por lote = un snapshot por lote); necesita staging-append → §7
row.editpgya cerrado en P·2 (409 row_not_addressable)
dataset.manifestpgMUDADO 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_rows porque 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 en payload, 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 ficheromedia_item_rid o filename—, 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 dataset content_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_parity están en la lista de §3: se borran con el plano, junto con el pickPortMode que 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):

EscritorEstado real
sync.full · sync.incremental · sync.append · cdc.appendYA tienen fila GLOBAL iceberg_native (desde 2026-06-30). No hay nada que flipear
pipeline.output · manual.table · ingest.api · sql-editor.dmlTecho iceberg_native, pero sin fila global ⇒ caen a defaultMode: 'pg' del registry

Lo 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.ts ya 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 en lakehouse_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ónQué se le quitó
create_workspace_branchNada: estaba limpia. Su única mención al overlay era un comentario de archive_workspace_branch
compute_pr_diffTodo 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_requestEl 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_log CAMBIÓ 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: ratify lo escribe y lo repara para que una escritura nativa sea atribuible, y reconcile/compensate lo 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_at ya no aparecen en ninguna línea de código del repo. El trinquete check:row-index-seam pasa 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_table declaraba SortOrder ASC sobre __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_*, includeIdentity en todo el contrato de la puerta, el gate «identidad ausente → degrada a PG», FreshnessVerdict.identityReady e icebergIdentityReady. Y el espejo en Python (sync_service.py + la ruta POST /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ño6.039 · 73 · 15 MB — idéntico a §1·①
② con espejo succeeded73/73 · datasets sólo en PG: 0 · filas que se perderían: 0
③ la ventana D·1→D·2ninguna fila posterior a su último espejo
④ las satélitelas 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

20261228 era la que PROTEGE el dato, no la que lo arriesga, y eso se vio al medir: los 5.193 objetos con puntero tenían properties = '{}', los 5.193. Toda su información vivía únicamente en dr.data — o sea, sólo en el plano PG. Sin este backfill, el DROP los habría dejado vacíos.

Verificado tras aplicarla: backfill 5.193/5.193, divergencia 0 entre base_properties y dr.data (la vista sirve exactamente lo mismo que antes: cambio invisible), 0 vistas dependen ya de dataset_rows, y las dos fn_instantiate_links_* fuera de ⑦. 20261258 es 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 read explicando 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

main só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 en main, no supuesto:

MigraciónQué sueltaQué lo usa en main
20261257lakehouse_read_flags · write_flags · read_parity · iceberg_sync_cursorread-router.ts:79 y write-router.ts:89 las consultan vía makeFlagLoader en cada lectura y cada escritura del seam. Soltarlas tumba producción en el acto
20261259dataset_branch_rows8 sitios, incluidos junction.ts (la puerta) y lib/branches/queries.ts
20261256las RPC transaccionales de la SDK3 rutas vivas de sdk/v1/…/transactions las llaman (aún no devuelven 410)
202612552 columnas de lakehouse_sync_partitionsel fan-out del sync-worker, que en main sigue 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 20261228 ya los desacopló (objects.base_properties denormaliza los datos de fila)». Esa migración existe en el repo y no está aplicada: objects.base_properties no 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 dice base_properties, la base dice dr.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 — y objects_resolved es 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 20261228 es un backfill que LEE dataset_rows. Si el DROP va 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ónQué haceNota
sdk_commit_file_batchESCRIBE (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_datasetsESCRIBE (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_ingestlee (×4)Sincroniza objetos tras ingesta
sdk_healthz_strictleeSó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ónVeredicto
sdk_commit_file_batch🏛️ MUDADA A INDEXEscribe 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🏛️ BACKFILLEADOSLa última lectura del plano de esta línea de trabajo. Cubiertos 11/11
reap_orphan_checkpoint_datasets🗑️ rama PG borradaEl borrado del dataset (Index) se queda; rows_freed pasa a ser 0 honesto
fn_sync_objects_after_ingest🗑️ BORRADANo 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 retiradosComprobaba 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_fkeysoltadaLa 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_rowssin 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_stagingsoltada. Era el aterrizaje del protocolo transaccional de la SDK, que está cerrado
sdk_file_stagingSE QUEDA. VIVA: la ruta de upload de batches escribe en ella y sdk_commit_file_batch la lee y la vacía. Es exactamente lo mismo que el manifiesto — control-plane, o sea 🏛️ Index
lakehouse_sync_partitionsSE QUEDA. VIVA: la usa base-polling-worker. Nació para el fan-out del espejo, pero el espejo se borró y ella siguió sirviendo al polling

Van ya tres veces que un elemento de esa lista resulta no serlo (antes: iceberg_sync_log, que había cambiado de significado, e iceberg_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/ingest caído.

Dos trampas que costaron un intento cada una, y las dos las cazó la auto-verificación

CREATE OR REPLACE no puede renombrar una columna del RETURNS TABLE. La firma viva era reaped_datasets, no reaped; 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ía sdk_healthz_strict por 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

PaqueteQué desbloquea
1D·1 — los bypasses de lectura (21 → 4, y los 4 no son lectores)Que dataset_rows no tenga lectores
2D·2 — el substrato lo decide la capacidad · ingest.stream cerrado · dataset.manifest mudado a IndexQue no tenga escritores
3D·3 — las RPC + SDK transaccional + las funciones mixtas de branch (dataset_branch_rows soltada)Que no tenga escritores dentro de Postgres
4D·4 — el cortejo de control + las 3 columnas de identidad a CEROQue nadie tenga que ELEGIR substrato
5D·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:

CapacidadEstado hoyLo 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)⛔ cerradaUn diseño de staging-append: bufferizar lotes → un snapshot al commit. open/write/commit no mapea a Iceberg tal cual
El grafo⛔ cerradoDecidir sobre qué motor compila (DuckDB, o dejar de compilar a SQL)
kuzu-service hydrator⛔ cerradoEs un paquete externo: necesita leer por HTTP contra la puerta, no por SQL
Fan-out de sync paraleloapagado (P·3)La concurrencia la da Iceberg; ya no un contador nuestro
Branches / PRs de datasetretiradas (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 PKsin dirección de filaDecisió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_table ya no declara SortOrder sobre 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.ts sobre 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).