🎯 Fuente de Verdad — Building blocks como consumidores puros del dataspace intrínseco
⚠️ PARCIALMENTE OBSOLETO (predata 2026-07-09). Estado real de la infra:
docs/INFRA.md. El framing "PG fuera" es aspiracional: hoy el read está mayormente por la facade pero el write sigue en PG (legacy/diferido). Catálogo activo = Lakekeeper (type=rest). Queda como registro de diseño.
Documento de arquitectura (approach del desacoplamiento). Objetivo: que cada building block de Carbon (pipelines, notebooks, ontología, training studio, apps, dashboards, exports, ML) sea un consumidor puro de una única fuente de verdad — sin lógica de datos por-bloque, sin pérdida de datos, coherente en todo el ciclo de vida del dato (entrada → derivación → salida).
Todo lo de "estado actual" está verificado contra el código (2026-07-03), no es aspiración. Companion: ../dataspaces/VALUE.md.
📋 Registro vivo de qué puerto está migrado vs pendiente: port-migration.md. La piedra fundacional (el contrato + la facade): dataspace-router.md.
1. El norte — un solo principio
La fuente de verdad = el dataspace intrínseco del cliente = tres planos que juntos describen cada item:
R2 (bytes Parquet) + Lakekeeper (punteros Iceberg) + Postgres (metadata del item + gobernanza + control plane)
─────────────────── ────────────────────────────── ──────────────────────────────────────────────────────
el DATO DÓNDE está el dato QUÉ es el dato (schema, PK, identidad, frescura, procedencia)
Invariante: un building block nunca toca R2, Lakekeeper ni dataset_rows directamente, nunca re-deriva propiedades del item (schema, PK, identidad, gobernanza) y nunca lleva lógica de datos propia. Recibe el item ya resuelto y lo consume. El item porta su naturaleza intrínseca desde que entra hasta que sale; los bloques solo la leen.
La regla de oro: la gobernanza (y el schema, y la PK, y la identidad, y el puntero) se adhiere al item, no se itera por building block. Igual que la PK ya funciona: se estampa una vez, vive en el item, se reenvía en los chokepoints de derivación, y N consumidores la leen sin re-derivarla. Replicamos ESE patrón para todo lo intrínseco.
2. La idea que lo unifica: tres hilos, un substrato
Lo que parecían tres proyectos son la misma fuente de verdad vista desde tres ángulos:
| Hilo | Qué plano toca | Rol en la fuente de verdad |
|---|---|---|
| Migración lakehouse "PG fuera" | R2 + Lakekeeper | el plano de datos del item (read/write nativo) |
| Gobernanza sobre el item (VALUE.md) | PG (metadata) | el plano de procedencia/gobernanza del item |
| Conector dataspace EDC | frontera | ingreso/egreso del item a través de la frontera org-a-org |
Un item gobernado es un producto de datos: internamente lo consumen los building blocks; en la frontera el conector EDC lo publica/consume. Mismo descriptor, distinta audiencia.
3. Estado actual — lo construido y los huecos (verificado)
El substrato existe (seams de lectura/escritura, control plane nativo, oráculo de frescura, columnas de identidad, contrato I/O documentado en docs/runbooks/karma-lakehouse-io-contract.md). Lo que falta es (a) un contrato único de consumo y (b) cerrar la cobertura. Mapa real:
3.1 Lectura — ¿el bloque lee del lakehouse o de PG?
Seam = resolveDatasetRowSource (read-router.ts:134); ~12/21 readers cableados.
| Estado | Building blocks |
|---|---|
| ✅ Iceberg activo | objects.sync, export.stream |
| ⚠️ Cableado, dormido (executor listo, espera flip + canary) | datasets.browse, datasets.rows.lookup, dashboards.aggregate, graph.explorer.{data,search,values}, graph.kuzu, datasets.export.legacy |
| ⚠️ Registrado sin executor (llama al seam pero cae siempre a PG) | sdk.rows, sdk.files.list, mediaset.item, model.schema.infer, pipeline.expectations |
| ❌ Bloqueo duro | PIPELINES (sourceCte.ts:101 hardcodea FROM dataset_rows, esquiva el seam), OBJECTS grid (lee la vista PG objects_resolved) |
| ↪️ Externo | ML training (ml-runner Python resuelve su tier vía snapshot ref) |
3.2 Escritura — ¿el output del bloque nace nativo o en PG?
Seam = resolveDatasetRowSink (write-router.ts); control plane nativo = withNativeControlPlane (iceberg-native-write.ts:111).
| Estado | Writers / chokepoints |
|---|---|
| ✅ Nativo R2 por defecto | sync.full, sync.incremental, sync.append, cdc.append; EDC landing (siempre R2) |
| ⚠️ Mixto (default PG, flippable) | ingest.api, manual.table |
| ❌ PG (diferido a "Fase 4 compute") | pipeline.output (outputs de pipeline y predicciones de modelo), ingest.stream, row.edit |
| ❌ Fuga de seam | transform-paths (transformPaths.ts hace INSERT INTO dataset_rows directo, sin pasar por el sink) |
3.3 Gobernanza — ¿sobrevive el ciclo de vida? (VALUE.md §5)
- ✅ Entrada: adherida al item en
project_files.metadata+datasets.service+ ledgerdataset_transactions.metadata. - ❌ Derivación: el output solo guarda
source_dataset_idinmediato — pierde la procedencia aguas arriba (counterparty, contrato, edc_transfer_id). - ❌ Lectura/UI: el About tiene el slot pero no renderiza; la API de datasets no devuelve
metadata. - ❌ Salida: el egreso (
table_export) no chequea la gobernanza.
3.4 El diagnóstico de fondo (verificado): consumo FRAGMENTADO
No existe un punto único de "cargar un item para consumir". Hoy cada building block orquesta a mano: fetch de datasets.schema → resolver el puntero (namespace.ds_{id}) → chequear frescura (icebergFreshness/icebergIdentityReady) → invocar el seam → decodificar columnas. Esa fragmentación es lo que impide que los bloques sean consumidores puros.
4. La pieza que falta (keystone): el Item Descriptor + loadItemForConsumption
Un único contrato que resuelve una vez todo lo intrínseco del item desde los tres planos y se lo entrega al bloque. El bloque deja de orquestar; se enchufa.
interface ItemDescriptor { // la materialización runtime del "dataspace intrínseco"
id: string; name: string; kind: 'dataset' | 'media_set' | …;
schema: DatasetColumn[]; // PG: datasets.schema (incl. isPrimaryKey)
identity: { hasIdentity: boolean; snapshotId: number | null }; // __row_index/__row_id
pointer: { namespace: string; icebergTable: string }; // Lakekeeper → R2
freshness:{ caughtUp: boolean; hasSucceededSnapshot: boolean }; // oráculo
governance:{ service: string; provenance: ProvenanceBlock; // project_files.metadata
schemaVersionId: string }; // + contrato/counterparty si viene de EDC
}
// Un solo shot: resuelve metadata + puntero + frescura + gobernanza, devuelve el descriptor + el I/O ya enrutado.
async function loadItemForConsumption(
consumerId: string, itemId: string, opts?: { workspaceId?: string },
): Promise<{ descriptor: ItemDescriptor; read: DatasetRowSource; write: DatasetRowSink }>;
Esto reúsa todo lo que ya existe (read-router, write-router, freshness, read-client, project_files.metadata) — solo lo empaqueta en un contrato. No es motor nuevo; es la fachada de consumidor puro.
5. El approach — desacoplamiento por fases (grafo de dependencias, no lista lineal)
┌──────────────────────────────────────────────┐
│ A. Item Descriptor + loadItemForConsumption │ ← keystone (no rompe nada; envuelve seams)
└───────────────┬───────────────┬───────────────┘
│ │
┌───────────────▼──┐ ┌────────▼─────────┐ ┌────────────────────────┐
│ B. Cobertura de │ │ D. Propagación de │ │ (independiente de A) │
│ lectura │ │ gobernanza en │ │ D puede arrancar YA │
│ (wire dormidos + │ │ los chokepoints│ └────────────────────────┘
│ registrados) │ │ (clon patrón PK) │
└───────┬──────────┘ └────────┬──────────┘
│ │
┌───────▼──────────┐ ┌─────────▼─────────────────────┐
│ C. LINCHPIN: │ │ E. Superficiar + enforcar │
│ pipelines sobre │ │ (About badge · linaje inter- │
│ el lakehouse │ │ item · chequeo de egreso) │
│ (sourceCte) │ └───────────────────────────────┘
└──────────────────┘
Fase A — el contrato de consumidor puro (keystone; no-breaking)
Construir loadItemForConsumption + ItemDescriptor. Envuelve los seams existentes; los bloques migran uno a uno al contrato (empezando por los ya cableados). Efecto: fetch de schema, resolución de puntero, gate de frescura y decode dejan de estar duplicados por-bloque. Aquí es donde la gobernanza, la frescura y el puntero se bundlean en el item.
📐 Diseño fundacional completo (la piedra): dataspace-router.md — el dataspace como router global validado contra Unity Catalog / Polaris / Foundry; el contrato de puerto (
DatasetHandle), las 8 responsabilidades del router, la forma híbrida (facade-first → veneer de servicio → credential vending), el orden "piedra primero" de 8 pasos y las decisiones abiertas. Es el "cómo" concreto de esta Fase A.
Fase B — cerrar la cobertura de lectura (incremental, ops-gated)
Los 6 registrados-sin-executor (sdk.rows, sdk.files, mediaset.item, model.schema.infer, pipeline.expectations) y el grid de objects (hoy vía vista PG): darles executor Iceberg. Flipear los dormidos (datasets.browse, graph.explorer.*, dashboards, kuzu) con el runbook de canary (parity gate + fallback PG + rollback ~10s). Guardarraíl: npm run check:dataset-rows-seam.
Fase C — el LINCHPIN: pipelines sobre el lakehouse (bloqueo duro)
sourceCte.ts:101 lee FROM dataset_rows y esquiva el seam → por eso el output de pipeline se queda en PG (pipeline.output maxMode=pg). Arreglar la lectura desbloquea la escritura a la vez. Dos caminos (decisión de negocio):
- Opción A (hydrate-to-temp): para un input nativo, leer vía
/lakehouse/reada una temp table con formadataset_rows, apuntar el CTE ahí → el compilador y los joins corren sin tocar. Reúso 100%; caveat de escala (mete el input en PG por build). - Opción B (Karma push-down): el motor Iceberg externo compila/lee. La preferida a largo plazo; difiere hasta que Karma aterrice.
Además: rutar transform-paths por el write-seam (cerrar la fuga de INSERT INTO dataset_rows directo). Al cerrar C, el pipeline pasa de ser el hueco a ser consumidor y productor nativo — y pipeline.output flipea a iceberg_native.
Fase D — propagación de gobernanza en los chokepoints (barato, alto valor, independiente)
Clonar el patrón de la PK: un bloque provenance/governance que se reenvía del source al output en los ~4 chokepoints de creación de datasets derivados (dataset-output-materialiser.ts createDatasetArtifact, model-node, transformPaths, checkpoint-ephemeral). Así la gobernanza sobrevive la derivación: un output de un dataset edc_consumer hereda {counterparty, agreement, edc_transfer_id, upstream_lineage}. Puede arrancar ya (no depende de la migración de datos).
Fase E — superficiar + enforcar (depende de A+D)
- About del item: badge "🔒 Importado bajo contrato de <partner>" leyendo
descriptor.governance(extender la API de datasets para devolverproject_files.metadata). - Linaje inter-item: unificar las aristas dataset→dataset (hoy dispersas en
transform_paths,ml_model_training_data,dataspace_transfers.target_dataset_id,pipeline_output.source_dataset_id) + nodocounterparty. Es la trazabilidad navegable que cruza la frontera. - Egreso:
table_exportchequeadescriptor.governanceantes de dejar salir el dato ("¿bajo qué contrato / con qué límite de propósito/retención?").
6. Guardarraíles — cómo se mantiene "sin pérdida, coherente" durante la migración
- Ratchet
check:dataset-rows-seam— falla si un fichero tocadataset_rowsfuera del seam (evita nuevas fugas como la de transform-paths). ⚠️ CORRECCIÓN (2026-07-29): NO es un CI ratchet — es un comando manual. El único workflow del repo (ml-runner-tests.yml) está filtrado aservices/ml-runner/**; no hay.huskyypackage.jsonno lo encadena abuild/test/lint. Además su perímetro es.tsbajoapp/+lib/+services/: no ve los 6 RPC de migraciones que escribendataset_rowsdesde dentro de Postgres, niscripts/, ni.tsx. Cablearlo a CI es prerequisito del plan de retirada (warehouse-scaffolding-retirement.md §5). - Canary de paridad — cuenta + spot-check alineado por
__row_indexPG↔Iceberg antes de cada flip. - Gate de frescura (STRICT
caughtUp/ EVENTUALhasSucceededSnapshot) + fallback PG transparente + fail-loud en el write nativo (sin fallback → nunca split-brain). - Identidad (
__row_index/__row_id) como ancla de reproducibilidad y keyset — el descriptor la expone, nunca la re-deriva el bloque.
7. Qué NO hacer (los anti-patrones que este approach evita)
- ❌ Lógica de datos por building block. Los bloques son consumidores; toda resolución vive en el descriptor.
- ❌ Iterar los N building blocks para propagar gobernanza. Se propaga en los ~4 chokepoints de derivación (Fase D), no en cada bloque.
- ❌ Motor paralelo. El descriptor reúsa los seams; no duplica read/write/catálogo.
- ❌ Que la gobernanza se evapore. Adherida al item, reenviada en derivación, surfaceada y enforzada en las fronteras (lectura/egreso).
8. Orden recomendado
- D (propagación de gobernanza) — barato, independiente, cierra el agujero conceptual "se evapora al derivar".
- A (Item Descriptor) — keystone; habilita consumidor puro y bundlea gobernanza/frescura/puntero.
- B (cobertura de lectura) — migrar bloques al descriptor + flip canary-gated.
- C (pipelines/sourceCte) — el bloqueo duro; máximo desbloqueo (lectura+escritura del pipeline).
- E (superficiar+enforcar) — la insignia de trazabilidad y el chequeo de egreso.
Documento vivo. Es el approach del desacoplamiento "building blocks = consumidores puros". Mantener al día conforme cada fase aterrice.