Published

🎯 Fuente de Verdad — Building blocks como consumidores puros del dataspace intrínseco

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

🎯 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:

HiloQué plano tocaRol en la fuente de verdad
Migración lakehouse "PG fuera"R2 + Lakekeeperel 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 EDCfronteraingreso/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.

EstadoBuilding blocks
Iceberg activoobjects.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 duroPIPELINES (sourceCte.ts:101 hardcodea FROM dataset_rows, esquiva el seam), OBJECTS grid (lee la vista PG objects_resolved)
↪️ ExternoML 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).

EstadoWriters / chokepoints
Nativo R2 por defectosync.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 seamtransform-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 + ledger dataset_transactions.metadata.
  • Derivación: el output solo guarda source_dataset_id inmediato — 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/read a una temp table con forma dataset_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 devolver project_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) + nodo counterparty. Es la trazabilidad navegable que cruza la frontera.
  • Egreso: table_export chequea descriptor.governance antes 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 toca dataset_rows fuera 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 a services/ml-runner/**; no hay .husky y package.json no lo encadena a build/test/lint. Además su perímetro es .ts bajo app/+lib/+services/: no ve los 6 RPC de migraciones que escriben dataset_rows desde dentro de Postgres, ni scripts/, 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_index PG↔Iceberg antes de cada flip.
  • Gate de frescura (STRICT caughtUp / EVENTUAL hasSucceededSnapshot) + 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

  1. D (propagación de gobernanza) — barato, independiente, cierra el agujero conceptual "se evapora al derivar".
  2. A (Item Descriptor) — keystone; habilita consumidor puro y bundlea gobernanza/frescura/puntero.
  3. B (cobertura de lectura) — migrar bloques al descriptor + flip canary-gated.
  4. C (pipelines/sourceCte) — el bloqueo duro; máximo desbloqueo (lectura+escritura del pipeline).
  5. 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.