tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica

Pivote arquitectónico: Brahman maneja varios formatos legítimos de
"Card" (cada uno en su crate origen, shape preservado), y un único
brazo los lee y proyecta a UNA estructura interna canónica que
consumen UI runtime / storage / DHT / wire. Agregar formato nuevo
= agregar reader, sin tocar consumers.

Crate nuevo `crates/core/brahman-cards/`:
- Card { id, schema_version, lineage, label, extensions, body }:
  wrapper común con identidad legible. PartialEq omitido porque
  MonadManifest y nakui_ui_schema::Module no lo implementan.
- CardBody enum tagged: Ente(brahman_card::Card), Monad(MonadManifest),
  UiModule(nakui_ui_schema::Module). Convención: agregar variant +
  reader; consumers hacen `match { Ente(..) => ..., _ => skip }`.
- trait CardReader { name, can_read(&Value), read(Value) }.
- 3 readers: EnteJsonReader (payload+supervision), MonadJsonReader
  (members+cardinality), UiModuleJsonReader (entities+views+menu).
- Entry points load_card / load_card_with. Errores tipados.

13 tests integration: detection x3, dispatch+projection x3,
negative cases x2, sanity de orden, e2e desde disco, unsupported
extension, custom reader set, documented invariant.

13/13 verdes. Workspace build verde.

V1 NO hace (explícito): Nickel reader, templates, migración de
consumers, yahweh refactor, KCL→Nickel — todos en commits siguientes
para mantener este aislado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

09501911b7ec497215773d2cb7192a2e6ce17e02 · por Sergio · (hace 5 meses) · padre ffdfa6f8d · ver el árbol en este commit

Delta semántico · 39

crates/core/brahman-cards/src/lib.rs

crates/core/brahman-cards/src/readers.rs

7 archivos · +817 −0

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92ff428..f8995cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,93 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
+**Pivote arquitectónico** decidido en charla: Brahman maneja varios
+formatos legítimos de "Card" (cada formato vive en su crate origen y
+conserva su shape público), y un **único brazo** los lee, completa
+desde templates si vienen simplificados, y los proyecta a UNA sola
+estructura interna canónica que consumen UI runtime / storage / DHT /
+wire. Agregar un formato nuevo = agregar un reader, sin tocar
+consumers.
+
+**V1 en este commit**: estructura canónica + readers para los 3
+formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado
+para próximo commit).
+
+Crate nuevo `crates/core/brahman-cards/`:
+- **`Card { id, schema_version, lineage, label, extensions, body }`**:
+  wrapper común con identidad legible + extensiones forward-compat.
+  `id` como String (no `Ulid`) porque cada body variant usa un tipo
+  de id distinto (Ulid para Ente/Monad, slug human-friendly para
+  UiModule). PartialEq omitido del derive porque `MonadManifest` y
+  `nakui_ui_schema::Module` no lo implementan en sus crates origen.
+- **`CardBody`** enum etiquetado `kind`:
+  - `Ente(brahman_card::Card)` — entidad runtime con
+    payload/soma/supervision.
+  - `Monad(nouser_card::MonadManifest)` — agrupación semántica de
+    archivos.
+  - `UiModule(nakui_ui_schema::Module)` — descriptor de UI con
+    entities/views/menu.
+  - Convención: agregar variant nuevo + reader; los consumers que
+    sólo manejen algunos hacen `match { Ente(..) => ..., _ => skip }`.
+- **`trait CardReader`**: `name()` + `can_read(&Value) -> bool` +
+  `read(Value) -> Result<Card>`. El dispatcher prueba en orden y
+  delega al primero que matchee.
+- **3 readers concretos** (en `readers.rs`):
+  - `EnteJsonReader` — heurística: `payload` Y `supervision`
+    presentes simultáneamente.
+  - `MonadJsonReader` — heurística: `members` Y `cardinality`.
+  - `UiModuleJsonReader` — heurística: `entities` Y `views` Y
+    `menu`. El más específico, va primero en `default_readers()`.
+- **Entry points**:
+  - `load_card(path)` — abre archivo, dispatcha por extensión, dentro
+    de JSON prueba los readers default.
+  - `load_card_with(path, readers)` — variante con set custom para
+    apps que quieren restringir formatos.
+- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`,
+  `NoMatchingReader`, `ReaderFailed { reader, message }`,
+  `UnsupportedExtension { ext, supported }`.
+
+13 tests integration:
+- 3 detection tests (cada reader matchea sólo su shape, rechaza los
+  otros 2 + non-object).
+- 3 dispatch+projection tests (cada formato JSON cargado produce el
+  variant esperado con campos del wrapper bien derivados).
+- 2 negative cases (NoMatchingReader, non-object input).
+- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples
+  readers — defiende el contrato de orden documentado).
+- 1 e2e desde disco con `load_card_with`.
+- 1 unsupported extension.
+- 1 custom reader set (restringir a sólo Ente).
+- 1 documented invariant (extensions vacío en V1; si cambia, este
+  test se rompe como signal).
+
+13/13 verdes. Workspace build verde tras agregar el crate al
+`members[]` del workspace Cargo.toml.
+
+**Lo que NO hace V1** (explícito):
+- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda
+  aislada para no inflar este commit.
+- No define templates — los templates Nickel se diseñan junto al
+  reader Nickel (necesitan `merge` nativo de Nickel para fusionar
+  override + base).
+- No migra consumers. `nakui-ui` sigue cargando `module.json` con
+  `nakui_ui_schema::load_modules_from_dir` directo. La migración a
+  `brahman_cards::load_card` viene cuando V1 + Nickel + templates
+  estén estables.
+- No mueve los `extensions` del input a `Card.extensions` — los crates
+  origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`).
+  Documentado como decisión consciente.
+
+**Pendientes para próximos commits** (orden):
+1. Reader Nickel + template merge.
+2. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
+3. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`
+   (esperando hasta que el brazo + canónico estén estables).
+4. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
+   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
+5. card.k eliminado (es REFERENCE ONLY documentado).
+
 ### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
 Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
 sólo validaba **forma** (UUID parseable + trim de whitespace) — un
diff --git a/Cargo.lock b/Cargo.lock
index bac7bcf..bd4c96a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1194,6 +1194,19 @@ dependencies = [
  "wit-parser 0.230.0",
 ]
 
+[[package]]
+name = "brahman-cards"
+version = "0.1.0"
+dependencies = [
+ "brahman-card",
+ "nakui-ui-schema",
+ "nouser-card",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
 [[package]]
 name = "brahman-handshake"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index a4a8644..b0892c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
     # ============================================================
     "crates/core/brahman-card",
     "crates/core/brahman-card-wit",
+    "crates/core/brahman-cards",
     "crates/core/brahman-handshake",
     "crates/core/brahman-broker",
     "crates/core/brahman-admin",
diff --git a/crates/core/brahman-cards/Cargo.toml b/crates/core/brahman-cards/Cargo.toml
new file mode 100644
index 0000000..2ee3a6c
--- /dev/null
+++ b/crates/core/brahman-cards/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "brahman-cards"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — brazo unificado: lee múltiples formatos de Card (Ente/Monad/UiModule/...) y los proyecta a una estructura canónica única."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+brahman-card = { path = "../brahman-card" }
+nouser-card = { path = "../../modules/nouser/card" }
+nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
+
+[dev-dependencies]
+serde_json = { workspace = true }
diff --git a/crates/core/brahman-cards/src/lib.rs b/crates/core/brahman-cards/src/lib.rs
new file mode 100644
index 0000000..72cd18e
--- /dev/null
+++ b/crates/core/brahman-cards/src/lib.rs
@@ -0,0 +1,244 @@
+//! `brahman-cards` — brazo unificado de Cards.
+//!
+//! Brahman maneja varios formatos legítimos de "Card" (la unidad
+//! declarativa que describe identidad, datos, módulos, widgets, ...).
+//! Cada formato vive en su propio crate de origen y conserva su shape
+//! público; lo que este crate aporta es **un único punto de entrada**
+//! que sabe interpretar cada uno de ellos y proyectarlos a una sola
+//! estructura interna canónica [`Card`].
+//!
+//! Diseño:
+//!
+//! ```text
+//! ┌─────────────┐  ┌──────────────┐  ┌─────────────┐
+//! │ Ente JSON   │  │ Monad JSON   │  │ UiModule    │ … futuro
+//! │ (brahman-   │  │ (nouser-     │  │ (nakui-ui-  │
+//! │  card)      │  │  card)       │  │  schema)    │
+//! └─────┬───────┘  └──────┬───────┘  └──────┬──────┘
+//!       │                 │                 │
+//!       └────────┬────────┴────────┬────────┘
+//!                │  brahman-cards  │
+//!                │   (este crate)  │
+//!                └────────┬────────┘
+//!                         │
+//!                  ┌──────▼──────┐
+//!                  │   `Card`    │ ← único tipo canónico
+//!                  │   wrapper   │   que consumen UI runtime,
+//!                  │   común +   │   storage, DHT, wire.
+//!                  │   variant   │
+//!                  │   body      │
+//!                  └─────────────┘
+//! ```
+//!
+//! Los formatos NO se disuelven. Si en el futuro hay que soportar un
+//! formato simplificado nuevo, se agrega un reader acá y nadie aguas
+//! abajo se entera — siguen recibiendo `Card`.
+//!
+//! V1 (este commit) sólo soporta inputs JSON. La extensión a Nickel
+//! (con templates de defaults vía merge nativo de Nickel) llega en un
+//! commit separado para aislar la dependencia `nickel-lang-core`.
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use thiserror::Error;
+
+pub use brahman_card::Card as EnteCard;
+pub use nakui_ui_schema::Module as UiModuleSpec;
+pub use nouser_card::MonadManifest;
+
+/// Estructura canónica única que consumen los downstream del sistema
+/// (UI runtime, storage, DHT, wire). Cada formato input se proyecta
+/// a ésta vía un reader del brazo.
+///
+/// El wrapper común agrupa lo que TODOS los formatos comparten
+/// (identidad legible + extensiones forward-compat); el body preserva
+/// el typing rico de cada dominio sin colapsarlos.
+// PartialEq se omite porque algunos body variants vienen de crates
+// que no lo implementan (MonadManifest, nakui_ui_schema::Module).
+// Si downstream necesita igualdad, comparar via JSON round-trip o
+// agregar PartialEq en los crates origen.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Card {
+    /// Identificador opaco. String en el wrapper para no obligar a
+    /// los formatos a un mismo tipo concreto (Ente/Monad usan ULID,
+    /// UiModule usa slug human-friendly como `"sales_engine"`).
+    /// Cada reader documenta qué formato exige.
+    pub id: String,
+
+    /// Versión del schema canónico de este wrapper. Bump = romper
+    /// compat de los consumers downstream. Distinto de los
+    /// `schema_version` internos de cada body variant, que siguen
+    /// su propio versioning.
+    pub schema_version: u16,
+
+    /// Ancestro del que esta Card desciende (si aplica). Significado
+    /// específico al body variant (Ente: lineage del proceso; Monad:
+    /// split/merge de Mónada padre; UiModule: típicamente None).
+    #[serde(default)]
+    pub lineage: Option<String>,
+
+    /// Etiqueta humana legible. Cada reader la deriva del campo
+    /// equivalente del input (label/title/etc.).
+    pub label: String,
+
+    /// Campos no reconocidos del input se preservan acá. Permite
+    /// forward-compat: leer un input con campos nuevos no rompe la
+    /// carga, y volver a serializar conserva el extra.
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub extensions: BTreeMap<String, Value>,
+
+    /// Cuerpo tipado por dominio. La elección del variant es
+    /// responsabilidad del reader (basada en el input shape).
+    pub body: CardBody,
+}
+
+/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
+/// la shape del wrapper o las invariantes que comparten todos los
+/// variants.
+pub const CARD_SCHEMA_VERSION: u16 = 1;
+
+/// Variantes tipadas del body de [`Card`]. Una por dominio.
+///
+/// **Convención de extensión**: agregar un variant nuevo aquí + un
+/// reader que produzca ese variant. Los consumers que sólo manejen
+/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum CardBody {
+    /// Entidad runtime con proceso/payload/supervision (lo que era
+    /// `brahman_card::Card` directo).
+    Ente(EnteCard),
+
+    /// Agrupación semántica de archivos (Mónada de Nouser). No tiene
+    /// proceso; describe membership + signals semánticas (centroid,
+    /// keywords, lens).
+    Monad(MonadManifest),
+
+    /// Descriptor de módulo de UI: entities + views + menu + actions.
+    /// Lo que hoy lee la metainterface de Nakui desde
+    /// `examples/nakui-modules/<id>/module.json`.
+    UiModule(UiModuleSpec),
+}
+
+impl CardBody {
+    /// Etiqueta corta del variant — útil para mensajes de error y
+    /// dispatch en la UI sin necesitar match exhaustivo.
+    pub fn kind_name(&self) -> &'static str {
+        match self {
+            CardBody::Ente(_) => "ente",
+            CardBody::Monad(_) => "monad",
+            CardBody::UiModule(_) => "ui_module",
+        }
+    }
+}
+
+/// Errores de carga del brazo.
+#[derive(Debug, Error)]
+pub enum CardLoadError {
+    #[error("io: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("parse JSON: {0}")]
+    JsonParse(#[from] serde_json::Error),
+
+    #[error("ningún reader registrado matcheó el input (shape no reconocido)")]
+    NoMatchingReader,
+
+    #[error("reader '{reader}' falló: {message}")]
+    ReaderFailed { reader: &'static str, message: String },
+
+    #[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
+    UnsupportedExtension {
+        ext: String,
+        supported: Vec<&'static str>,
+    },
+}
+
+/// Trait de reader. Cada formato implementa una instancia.
+///
+/// El dispatcher del brazo (`load_card`) prueba los readers en el
+/// orden registrado y se queda con el primero cuyo `can_read`
+/// devuelve `true`. Por eso el orden importa: poner los más
+/// específicos antes que los más laxos.
+pub trait CardReader: Send + Sync {
+    /// Nombre del reader, para mensajes de error.
+    fn name(&self) -> &'static str;
+
+    /// Dado un JSON Value (el input ya parseado a serde Value),
+    /// decide si este reader puede manejarlo. Heurística estructural
+    /// — el shape del input identifica el formato, no flags
+    /// explícitos (los inputs legacy no los tienen).
+    fn can_read(&self, input: &Value) -> bool;
+
+    /// Produce el [`Card`] canónico. Sólo se llama si `can_read`
+    /// devolvió `true`.
+    fn read(&self, input: Value) -> Result<Card, CardLoadError>;
+}
+
+mod readers;
+pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
+
+/// Construye el set default de readers para inputs JSON. El orden
+/// es deliberado: el más específico (UiModule, que tiene `entities`
+/// y `views` simultáneamente) antes que el más laxo. Si dos readers
+/// matchean, gana el primero.
+pub fn default_readers() -> Vec<Box<dyn CardReader>> {
+    vec![
+        Box::new(UiModuleJsonReader),
+        Box::new(MonadJsonReader),
+        Box::new(EnteJsonReader),
+    ]
+}
+
+/// Carga un Card desde una ruta. Detecta formato por extensión, y
+/// dentro de JSON detecta el shape probando los readers default en
+/// orden.
+///
+/// Para custom reader sets, usar [`load_card_with`].
+pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
+    load_card_with(path, &default_readers())
+}
+
+/// Variante de [`load_card`] con readers custom. Útil para tests o
+/// para apps que quieren restringir formatos soportados.
+pub fn load_card_with(
+    path: impl AsRef<Path>,
+    readers: &[Box<dyn CardReader>],
+) -> Result<Card, CardLoadError> {
+    let path = path.as_ref();
+    let ext = path
+        .extension()
+        .and_then(|e| e.to_str())
+        .unwrap_or("")
+        .to_ascii_lowercase();
+    match ext.as_str() {
+        "json" => {
+            let bytes = std::fs::read(path)?;
+            let value: Value = serde_json::from_slice(&bytes)?;
+            dispatch_to_reader(value, readers)
+        }
+        other => Err(CardLoadError::UnsupportedExtension {
+            ext: other.to_string(),
+            supported: vec!["json"],
+        }),
+    }
+}
+
+/// Recorre los readers en orden, se queda con el primero que matchea
+/// y delega la conversión.
+fn dispatch_to_reader(
+    input: Value,
+    readers: &[Box<dyn CardReader>],
+) -> Result<Card, CardLoadError> {
+    for r in readers {
+        if r.can_read(&input) {
+            return r.read(input);
+        }
+    }
+    Err(CardLoadError::NoMatchingReader)
+}
diff --git a/crates/core/brahman-cards/src/readers.rs b/crates/core/brahman-cards/src/readers.rs
new file mode 100644
index 0000000..44b99cc
--- /dev/null
+++ b/crates/core/brahman-cards/src/readers.rs
@@ -0,0 +1,168 @@
+//! Readers V1: tres formatos JSON ya existentes en el monorepo.
+//!
+//! Cada reader implementa:
+//! - `can_read`: heurística estructural para decidir si el JSON es
+//!   suyo. No requiere flag explícito en el input — los inputs legacy
+//!   no los tienen.
+//! - `read`: deserializa el JSON al tipo del crate origen (sin tocarlo)
+//!   y lo envuelve en [`Card`] derivando los campos del wrapper.
+//!
+//! Convenciones para derivar el wrapper:
+//! - `id`: del campo `id` del input (cada formato lo expone). Si es
+//!   ULID se serializa a string canónico.
+//! - `label`: del campo `label`.
+//! - `lineage`: del campo `lineage` cuando existe (Ente/Monad).
+//! - `extensions`: campos JSON desconocidos respecto a la struct del
+//!   crate origen. Hoy lo mantenemos vacío (los crates origen ya
+//!   tienen sus propios `extensions` internos via `#[serde(flatten)]`)
+//!   — no duplicamos. Si en el futuro queremos mover el "extras" del
+//!   crate origen al wrapper, esta es la palanca.
+
+use serde_json::Value;
+
+use crate::{Card, CardBody, CardLoadError, CardReader, EnteCard, MonadManifest, UiModuleSpec, CARD_SCHEMA_VERSION};
+
+// ============================================================================
+// Ente (brahman-card)
+// ============================================================================
+
+/// Reader para el shape JSON de [`brahman_card::Card`].
+///
+/// Heurística de detección: el input tiene `payload` Y `supervision`
+/// — son los campos requeridos del schema Ente que ningún otro
+/// formato del monorepo tiene.
+pub struct EnteJsonReader;
+
+impl CardReader for EnteJsonReader {
+    fn name(&self) -> &'static str {
+        "ente-json"
+    }
+
+    fn can_read(&self, input: &Value) -> bool {
+        let obj = match input.as_object() {
+            Some(o) => o,
+            None => return false,
+        };
+        obj.contains_key("payload") && obj.contains_key("supervision")
+    }
+
+    fn read(&self, input: Value) -> Result<Card, CardLoadError> {
+        let id = pull_string(&input, "id").unwrap_or_default();
+        let label = pull_string(&input, "label").unwrap_or_default();
+        let lineage = pull_string(&input, "lineage");
+
+        let ente: EnteCard =
+            serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
+                reader: "ente-json",
+                message: e.to_string(),
+            })?;
+
+        Ok(Card {
+            id,
+            schema_version: CARD_SCHEMA_VERSION,
+            lineage,
+            label,
+            extensions: Default::default(),
+            body: CardBody::Ente(ente),
+        })
+    }
+}
+
+// ============================================================================
+// Monad (nouser-card)
+// ============================================================================
+
+/// Reader para el shape JSON de [`nouser_card::MonadManifest`].
+///
+/// Heurística: tiene `members` (BTreeSet<FileId>) Y `cardinality`
+/// (u32). La combinación es exclusiva del MonadManifest.
+pub struct MonadJsonReader;
+
+impl CardReader for MonadJsonReader {
+    fn name(&self) -> &'static str {
+        "monad-json"
+    }
+
+    fn can_read(&self, input: &Value) -> bool {
+        let obj = match input.as_object() {
+            Some(o) => o,
+            None => return false,
+        };
+        obj.contains_key("members") && obj.contains_key("cardinality")
+    }
+
+    fn read(&self, input: Value) -> Result<Card, CardLoadError> {
+        let id = pull_string(&input, "id").unwrap_or_default();
+        let label = pull_string(&input, "label").unwrap_or_default();
+        let lineage = pull_string(&input, "lineage");
+
+        let monad: MonadManifest =
+            serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
+                reader: "monad-json",
+                message: e.to_string(),
+            })?;
+
+        Ok(Card {
+            id,
+            schema_version: CARD_SCHEMA_VERSION,
+            lineage,
+            label,
+            extensions: Default::default(),
+            body: CardBody::Monad(monad),
+        })
+    }
+}
+
+// ============================================================================
+// UiModule (nakui-ui-schema)
+// ============================================================================
+
+/// Reader para el shape JSON de los `module.json` de la UI Nakui
+/// ([`nakui_ui_schema::Module`]).
+///
+/// Heurística: tiene `entities` Y `views` Y `menu`. Es el shape más
+/// específico del repo, así que va primero en el orden default — si
+/// matchea, ningún otro reader debería intentar.
+pub struct UiModuleJsonReader;
+
+impl CardReader for UiModuleJsonReader {
+    fn name(&self) -> &'static str {
+        "ui-module-json"
+    }
+
+    fn can_read(&self, input: &Value) -> bool {
+        let obj = match input.as_object() {
+            Some(o) => o,
+            None => return false,
+        };
+        obj.contains_key("entities") && obj.contains_key("views") && obj.contains_key("menu")
+    }
+
+    fn read(&self, input: Value) -> Result<Card, CardLoadError> {
+        let id = pull_string(&input, "id").unwrap_or_default();
+        let label = pull_string(&input, "label").unwrap_or_default();
+        // UiModule no tiene lineage en su schema, queda None.
+        let module: UiModuleSpec =
+            serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
+                reader: "ui-module-json",
+                message: e.to_string(),
+            })?;
+
+        Ok(Card {
+            id,
+            schema_version: CARD_SCHEMA_VERSION,
+            lineage: None,
+            label,
+            extensions: Default::default(),
+            body: CardBody::UiModule(module),
+        })
+    }
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+fn pull_string(v: &Value, key: &str) -> Option<String> {
+    v.get(key)?.as_str().map(|s| s.to_string())
+}
diff --git a/crates/core/brahman-cards/tests/integration.rs b/crates/core/brahman-cards/tests/integration.rs
new file mode 100644
index 0000000..4d105c2
--- /dev/null
+++ b/crates/core/brahman-cards/tests/integration.rs
@@ -0,0 +1,283 @@
+//! Integration tests del brazo brahman-cards.
+//!
+//! Cubre:
+//! 1. Cada reader matchea sólo el shape correcto.
+//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader
+//!    correcto sin ambigüedad.
+//! 3. Round-trip: cada formato JSON cargado produce el variant
+//!    esperado del Card canónico con los campos del wrapper bien
+//!    derivados.
+//! 4. Rechazo gracioso de inputs no-matched + extensiones no
+//!    soportadas.
+
+use std::collections::BTreeMap;
+
+use brahman_cards::{
+    default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader,
+    MonadJsonReader, UiModuleJsonReader,
+};
+use serde_json::{json, Value};
+
+/// Helper: dispatch in-process desde un Value, sin tocar disco.
+/// Reproduce la lógica interna del dispatcher para no exigir I/O en
+/// los tests.
+fn dispatch(input: Value, readers: &[Box<dyn CardReader>]) -> Result<Card, CardLoadError> {
+    for r in readers {
+        if r.can_read(&input) {
+            return r.read(input);
+        }
+    }
+    Err(CardLoadError::NoMatchingReader)
+}
+
+// ===========================================================================
+// Reader detection (can_read)
+// ===========================================================================
+
+#[test]
+fn ui_module_reader_detects_only_ui_module_shape() {
+    let r = UiModuleJsonReader;
+    let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []});
+    let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"});
+    let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0});
+    assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape");
+    assert!(!r.can_read(&ente), "no debe matchear Ente");
+    assert!(!r.can_read(&monad), "no debe matchear Monad");
+    assert!(!r.can_read(&Value::Null), "no debe matchear non-object");
+}
+
+#[test]
+fn ente_reader_detects_only_ente_shape() {
+    let r = EnteJsonReader;
+    let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
+    let monad = json!({"members": [], "cardinality": 0});
+    let ui = json!({"menu": [], "views": {}, "entities": []});
+    assert!(r.can_read(&ente));
+    assert!(!r.can_read(&monad));
+    assert!(!r.can_read(&ui));
+}
+
+#[test]
+fn monad_reader_detects_only_monad_shape() {
+    let r = MonadJsonReader;
+    let monad = json!({"members": [], "cardinality": 0});
+    let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
+    let ui = json!({"menu": [], "views": {}, "entities": []});
+    assert!(r.can_read(&monad));
+    assert!(!r.can_read(&ente));
+    assert!(!r.can_read(&ui));
+}
+
+// ===========================================================================
+// Dispatch + variant projection
+// ===========================================================================
+
+#[test]
+fn loads_ui_module_to_card_ui_module_variant() {
+    let input = json!({
+        "id": "sales_engine",
+        "label": "Ventas",
+        "description": "Demo",
+        "entities": [],
+        "menu": [{"label": "Stock", "view": "stock_list"}],
+        "views": {
+            "stock_list": {
+                "kind": "list",
+                "title": "Stock",
+                "entity": "Stock",
+                "columns": []
+            }
+        }
+    });
+    let card = dispatch(input, &default_readers()).expect("dispatch ok");
+    assert_eq!(card.id, "sales_engine");
+    assert_eq!(card.label, "Ventas");
+    assert!(card.lineage.is_none(), "UiModule sin lineage");
+    assert_eq!(card.body.kind_name(), "ui_module");
+    match card.body {
+        CardBody::UiModule(m) => {
+            assert_eq!(m.id, "sales_engine");
+            assert_eq!(m.menu.len(), 1);
+        }
+        other => panic!("variant inesperado: {:?}", other.kind_name()),
+    }
+}
+
+#[test]
+fn loads_ente_to_card_ente_variant() {
+    // Ulid mínimo: 26 chars Crockford. Usamos uno conocido.
+    let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
+    let input = json!({
+        "schema_version": 1,
+        "id": ulid,
+        "label": "test-ente",
+        "payload": "Virtual",
+        "supervision": "OneShot"
+    });
+    let card = dispatch(input, &default_readers()).expect("dispatch ok");
+    assert_eq!(card.id, ulid);
+    assert_eq!(card.label, "test-ente");
+    assert_eq!(card.body.kind_name(), "ente");
+    match card.body {
+        CardBody::Ente(e) => {
+            assert_eq!(e.label, "test-ente");
+            assert_eq!(e.id.to_string(), ulid);
+        }
+        other => panic!("variant inesperado: {:?}", other.kind_name()),
+    }
+}
+
+#[test]
+fn loads_monad_to_card_monad_variant() {
+    let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1";
+    let input = json!({
+        "schema_version": 1,
+        "id": ulid,
+        "label": "test-monad",
+        "members": [],
+        "cardinality": 0,
+        "created_at_ms": 0,
+        "updated_at_ms": 0
+    });
+    let card = dispatch(input, &default_readers()).expect("dispatch ok");
+    assert_eq!(card.id, ulid);
+    assert_eq!(card.label, "test-monad");
+    assert_eq!(card.body.kind_name(), "monad");
+    match card.body {
+        CardBody::Monad(m) => {
+            assert_eq!(m.label, "test-monad");
+            assert_eq!(m.cardinality, 0);
+        }
+        other => panic!("variant inesperado: {:?}", other.kind_name()),
+    }
+}
+
+// ===========================================================================
+// Negative cases
+// ===========================================================================
+
+#[test]
+fn rejects_input_no_matching_reader() {
+    let input = json!({"random": "shape", "without": "fingerprint"});
+    let err = dispatch(input, &default_readers()).unwrap_err();
+    assert!(
+        matches!(err, CardLoadError::NoMatchingReader),
+        "expected NoMatchingReader, got {err:?}"
+    );
+}
+
+#[test]
+fn rejects_non_object_input() {
+    let input = json!([1, 2, 3]);
+    let err = dispatch(input, &default_readers()).unwrap_err();
+    assert!(matches!(err, CardLoadError::NoMatchingReader));
+}
+
+#[test]
+fn ui_module_takes_priority_when_shape_overlaps_partial() {
+    // Sanity del orden: si alguien armara un input híbrido con
+    // `menu`+`views`+`entities` Y también `payload`+`supervision`,
+    // el UiModuleReader (primero en orden) debería ganar. Esto no
+    // debería ocurrir con inputs reales pero defendemos el contrato
+    // de orden documentado.
+    let input = json!({
+        "id": "weird",
+        "label": "Weird",
+        "menu": [],
+        "views": {},
+        "entities": [],
+        "payload": "Virtual",
+        "supervision": "OneShot"
+    });
+    let card = dispatch(input, &default_readers()).expect("dispatch ok");
+    assert_eq!(
+        card.body.kind_name(),
+        "ui_module",
+        "el UiModuleReader debería ganar por orden"
+    );
+}
+
+// ===========================================================================
+// load_card desde disco (e2e fino)
+// ===========================================================================
+
+#[test]
+fn load_card_from_disk_round_trip_ui_module() {
+    let tmp = tempfile_path("ui_module.json");
+    let input = json!({
+        "id": "demo",
+        "label": "Demo",
+        "entities": [],
+        "menu": [],
+        "views": {}
+    });
+    std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap();
+
+    let card = load_card_with(&tmp, &default_readers()).expect("load ok");
+    assert_eq!(card.body.kind_name(), "ui_module");
+    assert_eq!(card.id, "demo");
+
+    let _ = std::fs::remove_file(&tmp);
+}
+
+#[test]
+fn load_card_rejects_unsupported_extension() {
+    let tmp = tempfile_path("foo.toml");
+    std::fs::write(&tmp, b"[anything]\nx = 1").unwrap();
+    let err = load_card_with(&tmp, &default_readers()).unwrap_err();
+    match err {
+        CardLoadError::UnsupportedExtension { ext, supported } => {
+            assert_eq!(ext, "toml");
+            assert!(supported.contains(&"json"));
+        }
+        other => panic!("expected UnsupportedExtension, got {other:?}"),
+    }
+    let _ = std::fs::remove_file(&tmp);
+}
+
+// ===========================================================================
+// Custom reader sets
+// ===========================================================================
+
+#[test]
+fn custom_reader_set_can_restrict_supported_formats() {
+    // Sólo Ente: un input Monad debería rechazarse.
+    let only_ente: Vec<Box<dyn CardReader>> = vec![Box::new(EnteJsonReader)];
+    let monad_input = json!({"members": [], "cardinality": 0});
+    let err = dispatch(monad_input, &only_ente).unwrap_err();
+    assert!(matches!(err, CardLoadError::NoMatchingReader));
+}
+
+// ===========================================================================
+// Wrapper field invariants
+// ===========================================================================
+
+#[test]
+fn extensions_field_starts_empty_in_v1() {
+    // Documented: V1 no mueve el "extras" del crate origen al
+    // wrapper.extensions. Si esto cambia, este test se rompe como
+    // signal para actualizar el doc de readers.rs.
+    let input = json!({
+        "id": "demo",
+        "label": "Demo",
+        "entities": [],
+        "menu": [],
+        "views": {}
+    });
+    let card = dispatch(input, &default_readers()).unwrap();
+    assert_eq!(card.extensions, BTreeMap::new());
+}
+
+// ===========================================================================
+// Helpers de tests
+// ===========================================================================
+
+fn tempfile_path(name: &str) -> std::path::PathBuf {
+    let mut p = std::env::temp_dir();
+    p.push(format!(
+        "brahman-cards-test-{}-{}",
+        std::process::id(),
+        name
+    ));
+    p
+}