tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

feat(core): brahman-card — Tarjeta de Presentación canónica híbrida

Crate nuevo en crates/core/brahman-card que unifica el modelo de arje y
el de brahman/core_protocol original:

De arje (ente-card):
- Identidad ULID + lineage opcional
- Capability tipado (Spawn, Journal, Endpoint, Device, Netlink, ...)
- Payload discriminado (Wasm | Native | Virtual | Legacy)
- SomaSpec (namespaces, cgroups, rlimits, cpu_affinity)
- Supervision (Restart con backoff, OneShot, Delegate)
- genesis: Vec<Card> recursivo
- Validación exhaustiva (label, self-dep, payload, rlimits, cgroup)

Aditivo brahman:
- flow: Flows con TypeRef discriminado (Primitive | Wit)
- pin_to opcional como pista para el broker
- Permissions enumerados (NetworkingPolicy, FsPolicy, IpcPolicy)
- Lifecycle ortogonal (Daemon | Oneshot | Widget)
- Priority de scheduling
- TrustLevel derivado de Permissions (no declarado)
- ResolvedCard con WitInterface opcional
- extensions: HashMap para forward-compat

Formatos: JSON canónico + TOML, auto-detectados por extensión de archivo.

Tests: 8/8 incluyendo arje_seed_format_compatible que valida que el
formato JSON existente de arje sigue parseando con defaults para los
campos nuevos.

ente-card original sigue intacto; los consumidores (ente-zero, etc.)
migrarán en una pasada posterior.

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

0feba74503dd7b92dcd0c61517196ca698cd9629 · por Sergio · (hace 5 meses) · padre 4d50bfc58 · ver el árbol en este commit

Delta semántico · 52

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

4 archivos · +827 −0

diff --git a/Cargo.lock b/Cargo.lock
index 5aea1c2..de3b45f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1139,6 +1139,17 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "brahman-card"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "toml 0.8.23",
+ "ulid",
+]
+
 [[package]]
 name = "bs58"
 version = "0.5.1"
diff --git a/Cargo.toml b/Cargo.toml
index 196b0d2..671bdaa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
     # ============================================================
     # core/ — Init y compat (arje absorbido)
     # ============================================================
+    "crates/core/brahman-card",
     "crates/core/ente-card",
     "crates/core/ente-bus",
     "crates/core/ente-cas",
diff --git a/crates/core/brahman-card/Cargo.toml b/crates/core/brahman-card/Cargo.toml
new file mode 100644
index 0000000..ff8c003
--- /dev/null
+++ b/crates/core/brahman-card/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "brahman-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — Tarjeta de Presentación canónica (identidad arje + flujos tipados brahman)."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/core/brahman-card/src/lib.rs b/crates/core/brahman-card/src/lib.rs
new file mode 100644
index 0000000..7f19910
--- /dev/null
+++ b/crates/core/brahman-card/src/lib.rs
@@ -0,0 +1,799 @@
+//! `brahman-card` — Tarjeta de Presentación canónica de Brahman.
+//!
+//! Híbrida del `EntityCard` de arje (identidad ULID, capacidades tipadas,
+//! `Payload`/`SomaSpec`/`Supervision`/`genesis` recursivo) con flujos tipados,
+//! permisos enumerados explícitos y nivel de confianza derivado del modelo
+//! que veníamos diseñando en `core_protocol`. Una sola tarjeta sirve a:
+//!
+//! - **El Init** (encarnación): `payload` + `soma` + `supervision` + `genesis`.
+//! - **El Admin** (matching): `provides`/`requires` + `flow` + `permissions`.
+//! - **El runtime** (sandbox): `permissions` enumerados → seccomp / namespaces.
+//!
+//! Forward-compat: cualquier campo desconocido se preserva en `extensions`
+//! (raíz) o en `extra` (sub-secciones).
+//!
+//! Formatos soportados: JSON (canónico, compatible con arje) y TOML
+//! (humano-legible). Auto-detección por extensión.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::{BTreeSet, HashMap, HashSet};
+use std::path::Path;
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use thiserror::Error;
+use ulid::Ulid;
+
+/// Versión del esquema de la Card.
+pub const CARD_SCHEMA_VERSION: u16 = 1;
+
+/// Versión del protocolo Brahman.
+pub const PROTOCOL_VERSION: &str = "0.1.0";
+
+/// Errores de parseo o validación de la Card.
+#[derive(Debug, Error)]
+pub enum CardError {
+    #[error("schema version mismatch: got {got}, expected {expected}")]
+    SchemaMismatch { got: u16, expected: u16 },
+    #[error("label vacío")]
+    EmptyLabel,
+    #[error("label demasiado largo: {0} bytes (máx 256)")]
+    LabelTooLong(usize),
+    #[error("capacidad presente en provides Y requires: {0:?}")]
+    SelfDependency(Capability),
+    #[error("payload Native/Legacy con exec vacío")]
+    EmptyExec,
+    #[error("payload Wasm con sha256 sentinela (todo ceros)")]
+    SentinelWasmHash,
+    #[error("rlimit inválido: {0}")]
+    InvalidRlimit(&'static str),
+    #[error("cgroup weight fuera de [1,10000]: {0}")]
+    InvalidCgroupWeight(&'static str),
+    #[error("flujo {section}: nombre duplicado '{name}'")]
+    DuplicateFlowName {
+        section: &'static str,
+        name: String,
+    },
+    #[error("JSON inválido: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("TOML inválido: {0}")]
+    Toml(#[from] toml::de::Error),
+    #[error("E/S leyendo card: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("formato desconocido (extensiones esperadas: .json, .toml)")]
+    UnknownFormat,
+}
+
+// =====================================================================
+// Card raíz
+// =====================================================================
+
+/// Tarjeta de Presentación de un módulo Brahman.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Card {
+    /// Versión del esquema. Cambiar = romper compatibilidad del fractal.
+    pub schema_version: u16,
+
+    /// Identidad opaca, única en el grafo del fractal.
+    pub id: Ulid,
+
+    /// Ancestro del que esta Card desciende (genealogía).
+    #[serde(default)]
+    pub lineage: Option<Ulid>,
+
+    /// Nombre humano-legible. Único por convención, no por validación.
+    pub label: String,
+
+    /// Capacidades del sistema que esta Card ofrece a otros.
+    #[serde(default)]
+    pub provides: BTreeSet<Capability>,
+
+    /// Capacidades que necesita resolver el Init antes de encarnarla.
+    #[serde(default)]
+    pub requires: BTreeSet<Capability>,
+
+    /// Permisos sandbox declarativos (más alto nivel que `Capability`).
+    /// El Admin los compila a seccomp/namespaces/cgroups concretos.
+    #[serde(default)]
+    pub permissions: Permissions,
+
+    /// Spec runtime Linux (namespaces, cgroups, rlimits, cpu_affinity).
+    #[serde(default)]
+    pub soma: SomaSpec,
+
+    /// Qué encarnar: WASM, ELF nativo, virtual, o legacy con shims.
+    pub payload: Payload,
+
+    /// Política de supervisión (restart con backoff, oneshot, delegada).
+    pub supervision: Supervision,
+
+    /// Modelo de ejecución (eje ortogonal a `supervision`).
+    #[serde(default)]
+    pub lifecycle: Lifecycle,
+
+    /// Prioridad de scheduling.
+    #[serde(default)]
+    pub priority: Priority,
+
+    /// Contratos de flujo de datos: qué consume, qué produce.
+    #[serde(default)]
+    pub flow: Flows,
+
+    /// Hijas a instanciar inmediatamente al encarnar esta Card.
+    #[serde(default)]
+    pub genesis: Vec<Card>,
+
+    /// Campos desconocidos preservados intactos (forward-compat).
+    #[serde(flatten, default)]
+    pub extensions: HashMap<String, Value>,
+}
+
+// =====================================================================
+// Capacidades — heredadas de arje, tipadas, no strings
+// =====================================================================
+
+/// Capacidad del sistema. Identificadores tipados, no strings libres.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum Capability {
+    /// Provee un punto de montaje root para Cards hijas.
+    FilesystemRoot,
+    /// Acceso a una familia netlink del kernel.
+    KernelNetlink(NetlinkFamily),
+    /// Endpoint del bus interno — equivalente tipado de un nombre D-Bus.
+    Endpoint {
+        interface: InterfaceId,
+        version: u16,
+    },
+    /// Reemplazo del shim de systemd-logind. Solo el ente compat lo provee.
+    LegacyLogind,
+    /// Acceso crudo a una clase de dispositivo. Capacidad escalada.
+    Device { class: DeviceClass },
+    /// Permiso de instanciar Cards hijas. Por defecto solo PID 1 lo tiene.
+    Spawn,
+    /// Acceso a logging estructurado del fractal.
+    Journal,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum NetlinkFamily {
+    Uevent,
+    Route,
+    Generic,
+    Audit,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum DeviceClass {
+    Block,
+    Tty,
+    Input,
+    Drm,
+    Net,
+    Hidraw,
+}
+
+/// Identificador de interfaz del bus interno (UUID, no string libre).
+/// Para extender el protocolo, se genera un UUID nuevo y se versiona.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct InterfaceId(pub [u8; 16]);
+
+// =====================================================================
+// Permisos sandbox — más alto nivel que Capability
+// =====================================================================
+
+/// Permisos declarativos. El Admin los traduce a seccomp/namespaces.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Permissions {
+    #[serde(default)]
+    pub networking: NetworkingPolicy,
+    #[serde(default)]
+    pub filesystem: FsPolicy,
+    #[serde(default)]
+    pub ipc: IpcPolicy,
+    /// Capacidad de spawnear sub-procesos. Implica `TrustLevel::System`.
+    #[serde(default)]
+    pub processes: bool,
+    #[serde(flatten, default)]
+    pub extra: HashMap<String, Value>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum NetworkingPolicy {
+    #[default]
+    None,
+    Loopback,
+    Outbound,
+    Full,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum FsPolicy {
+    #[default]
+    None,
+    ReadOnly,
+    ReadWrite,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct IpcPolicy {
+    /// Protocolos IPC permitidos (p. ej. `"wit-v1"`, `"shm-v1"`).
+    #[serde(default)]
+    pub allow: Vec<String>,
+}
+
+// =====================================================================
+// SomaSpec — runtime Linux (heredado de arje sin cambios)
+// =====================================================================
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct SomaSpec {
+    pub namespaces: NamespaceSet,
+    pub rlimits: ResourceLimits,
+    pub cgroup: CgroupSpec,
+    pub cpu_affinity: Option<Vec<u32>>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct NamespaceSet {
+    pub mount: bool,
+    pub pid: bool,
+    pub net: bool,
+    pub uts: bool,
+    pub ipc: bool,
+    pub user: bool,
+    pub cgroup: bool,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct ResourceLimits {
+    pub mem_bytes: Option<u64>,
+    pub nproc: Option<u32>,
+    pub nofile: Option<u32>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct CgroupSpec {
+    #[serde(default)]
+    pub path: String,
+    pub cpu_weight: Option<u32>,
+    pub io_weight: Option<u32>,
+}
+
+// =====================================================================
+// Payload — qué encarnar (heredado de arje)
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Payload {
+    Wasm {
+        module_sha256: [u8; 32],
+        entry: String,
+    },
+    Native {
+        exec: String,
+        argv: Vec<String>,
+        envp: Vec<(String, String)>,
+    },
+    /// Sin proceso. Nodo lógico del grafo (agregadores, mediators).
+    Virtual,
+    /// Wrapper de daemon legacy. `fakes` activa shims D-Bus / sd_notify.
+    Legacy {
+        exec: String,
+        argv: Vec<String>,
+        fakes: BTreeSet<LegacyFacade>,
+    },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum LegacyFacade {
+    SystemdLogind,
+    SystemdHostnamed,
+    SystemdNotify,
+}
+
+// =====================================================================
+// Supervisión (heredada de arje)
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Supervision {
+    Restart {
+        #[serde(with = "duration_millis")]
+        initial: Duration,
+        #[serde(with = "duration_millis")]
+        max: Duration,
+    },
+    OneShot,
+    Delegate,
+}
+
+mod duration_millis {
+    use serde::{Deserialize, Deserializer, Serializer};
+    use std::time::Duration;
+
+    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
+        s.serialize_u64(d.as_millis() as u64)
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
+        let ms = u64::deserialize(d)?;
+        Ok(Duration::from_millis(ms))
+    }
+}
+
+// =====================================================================
+// Lifecycle / Priority (del modelo brahman)
+// =====================================================================
+
+/// Modelo de ejecución (rol). Ortogonal a `Supervision` (política de restart).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Lifecycle {
+    /// Servicio de larga duración.
+    #[default]
+    Daemon,
+    /// Una sola ejecución; sale al terminar su tarea.
+    Oneshot,
+    /// Componente UI gestionado por el motor de widgets.
+    Widget,
+}
+
+/// Prioridad de scheduling.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Priority {
+    Low,
+    #[default]
+    Normal,
+    High,
+    Critical,
+}
+
+// =====================================================================
+// Flujos tipados (del modelo brahman)
+// =====================================================================
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Flows {
+    #[serde(default)]
+    pub input: Vec<Flow>,
+    #[serde(default)]
+    pub output: Vec<Flow>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Flow {
+    /// Nombre único dentro de su dirección.
+    pub name: String,
+    /// Tipo de los datos que viajan por el flujo.
+    #[serde(rename = "type")]
+    pub ty: TypeRef,
+    /// Sugerencia de productor/consumidor concreto. El broker la respeta
+    /// como pista; cae en matching por tipo si no es resoluble.
+    #[serde(default)]
+    pub pin_to: Option<String>,
+}
+
+/// Referencia a un tipo, discriminada para distinguir primitivas de tipos WIT.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "lowercase")]
+pub enum TypeRef {
+    /// Tipo primitivo del runtime.
+    Primitive { name: String },
+    /// Tipo declarado en un paquete WIT.
+    Wit {
+        package: String,
+        #[serde(default)]
+        interface: Option<String>,
+        name: String,
+    },
+}
+
+// =====================================================================
+// API: parseo y validación
+// =====================================================================
+
+impl Card {
+    /// Deserializa una Card desde JSON y valida.
+    pub fn from_json(src: &str) -> Result<Self, CardError> {
+        let c: Self = serde_json::from_str(src)?;
+        c.validate()?;
+        Ok(c)
+    }
+
+    /// Deserializa una Card desde TOML y valida.
+    pub fn from_toml(src: &str) -> Result<Self, CardError> {
+        let c: Self = toml::from_str(src)?;
+        c.validate()?;
+        Ok(c)
+    }
+
+    /// Carga una Card desde disco. Auto-detecta formato por extensión
+    /// (`.json` o `.toml`).
+    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, CardError> {
+        let p = path.as_ref();
+        let src = std::fs::read_to_string(p)?;
+        match p.extension().and_then(|e| e.to_str()) {
+            Some("json") => Self::from_json(&src),
+            Some("toml") => Self::from_toml(&src),
+            _ => Err(CardError::UnknownFormat),
+        }
+    }
+
+    /// Re-serializa la Card a JSON con indentación.
+    pub fn to_json_pretty(&self) -> Result<String, CardError> {
+        Ok(serde_json::to_string_pretty(self)?)
+    }
+
+    /// Validación semántica exhaustiva, recursiva sobre `genesis`.
+    pub fn validate(&self) -> Result<(), CardError> {
+        if self.schema_version != CARD_SCHEMA_VERSION {
+            return Err(CardError::SchemaMismatch {
+                got: self.schema_version,
+                expected: CARD_SCHEMA_VERSION,
+            });
+        }
+        if self.label.is_empty() {
+            return Err(CardError::EmptyLabel);
+        }
+        if self.label.len() > 256 {
+            return Err(CardError::LabelTooLong(self.label.len()));
+        }
+        for cap in &self.requires {
+            if self.provides.contains(cap) {
+                return Err(CardError::SelfDependency(cap.clone()));
+            }
+        }
+        validate_payload(&self.payload)?;
+        validate_rlimits(&self.soma.rlimits)?;
+        validate_cgroup(&self.soma.cgroup)?;
+        check_unique_flow_names(&self.flow.input, "flow.input")?;
+        check_unique_flow_names(&self.flow.output, "flow.output")?;
+        for child in &self.genesis {
+            child.validate()?;
+        }
+        Ok(())
+    }
+}
+
+fn validate_payload(p: &Payload) -> Result<(), CardError> {
+    match p {
+        Payload::Native { exec, .. } | Payload::Legacy { exec, .. } => {
+            if exec.trim().is_empty() {
+                return Err(CardError::EmptyExec);
+            }
+        }
+        Payload::Wasm { module_sha256, .. } => {
+            if module_sha256.iter().all(|&b| b == 0) {
+                return Err(CardError::SentinelWasmHash);
+            }
+        }
+        Payload::Virtual => {}
+    }
+    Ok(())
+}
+
+fn validate_rlimits(rl: &ResourceLimits) -> Result<(), CardError> {
+    if let Some(m) = rl.mem_bytes {
+        if m == 0 {
+            return Err(CardError::InvalidRlimit("mem_bytes=0"));
+        }
+        if m > 1u64 << 40 {
+            return Err(CardError::InvalidRlimit("mem_bytes>1TiB"));
+        }
+    }
+    if let Some(n) = rl.nproc {
+        if n == 0 || n > 65535 {
+            return Err(CardError::InvalidRlimit("nproc fuera de [1,65535]"));
+        }
+    }
+    if let Some(n) = rl.nofile {
+        if n == 0 || n > 1_048_576 {
+            return Err(CardError::InvalidRlimit("nofile fuera de [1,1M]"));
+        }
+    }
+    Ok(())
+}
+
+fn validate_cgroup(cg: &CgroupSpec) -> Result<(), CardError> {
+    if let Some(w) = cg.cpu_weight {
+        if !(1..=10000).contains(&w) {
+            return Err(CardError::InvalidCgroupWeight("cpu_weight"));
+        }
+    }
+    if let Some(w) = cg.io_weight {
+        if !(1..=10000).contains(&w) {
+            return Err(CardError::InvalidCgroupWeight("io_weight"));
+        }
+    }
+    Ok(())
+}
+
+fn check_unique_flow_names(flows: &[Flow], section: &'static str) -> Result<(), CardError> {
+    let mut seen = HashSet::new();
+    for f in flows {
+        if !seen.insert(f.name.as_str()) {
+            return Err(CardError::DuplicateFlowName {
+                section,
+                name: f.name.clone(),
+            });
+        }
+    }
+    Ok(())
+}
+
+// =====================================================================
+// Trust derivado
+// =====================================================================
+
+/// Nivel de confianza derivado de los permisos. **No es un campo declarado** —
+/// se calcula. Una sola fuente de verdad: lo que el Admin concede.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum TrustLevel {
+    /// Sin permisos — sandbox total.
+    Untrusted,
+    /// Permisos menores (loopback, FS read-only, IPC).
+    Sandboxed,
+    /// Permisos amplios (red saliente o FS read-write).
+    Privileged,
+    /// Capacidad de spawnear procesos.
+    System,
+}
+
+impl TrustLevel {
+    /// Política de derivación:
+    /// - `processes = true` ⇒ `System`.
+    /// - FS `read-write` o networking `outbound`/`full` ⇒ `Privileged`.
+    /// - FS `read-only`, networking `loopback`, o cualquier IPC ⇒ `Sandboxed`.
+    /// - Sin permisos ⇒ `Untrusted`.
+    pub fn derive(p: &Permissions) -> Self {
+        if p.processes {
+            return Self::System;
+        }
+        if matches!(p.filesystem, FsPolicy::ReadWrite)
+            || matches!(
+                p.networking,
+                NetworkingPolicy::Outbound | NetworkingPolicy::Full
+            )
+        {
+            return Self::Privileged;
+        }
+        if matches!(p.filesystem, FsPolicy::ReadOnly)
+            || matches!(p.networking, NetworkingPolicy::Loopback)
+            || !p.ipc.allow.is_empty()
+        {
+            return Self::Sandboxed;
+        }
+        Self::Untrusted
+    }
+}
+
+// =====================================================================
+// Identidad runtime (Card + WIT extraído + trust)
+// =====================================================================
+
+/// Resumen de la interfaz WIT extraída del componente WASM/WIT.
+/// Vacío para módulos agnósticos (sin contrato WIT).
+#[derive(Debug, Clone, Default)]
+pub struct WitInterface {
+    pub package: String,
+    pub world: String,
+    pub exports: Vec<String>,
+    pub imports: Vec<String>,
+}
+
+/// Card resuelta a runtime: schema + WIT opcional + trust derivado.
+/// Es lo que el Admin indexa.
+#[derive(Debug, Clone)]
+pub struct ResolvedCard {
+    pub card: Card,
+    /// `Some` si el módulo es consciente (expone WIT), `None` si es agnóstico.
+    pub wit: Option<WitInterface>,
+    pub trust: TrustLevel,
+}
+
+impl ResolvedCard {
+    /// Construye una Card resuelta sin información WIT.
+    pub fn from_agnostic(card: Card) -> Self {
+        let trust = TrustLevel::derive(&card.permissions);
+        Self {
+            card,
+            wit: None,
+            trust,
+        }
+    }
+
+    /// Construye una Card resuelta con interfaz WIT extraída.
+    pub fn from_conscious(card: Card, wit: WitInterface) -> Self {
+        let trust = TrustLevel::derive(&card.permissions);
+        Self {
+            card,
+            wit: Some(wit),
+            trust,
+        }
+    }
+}
+
+// =====================================================================
+// Tests
+// =====================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_card_json() -> &'static str {
+        r#"{
+            "schema_version": 1,
+            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
+            "lineage": null,
+            "label": "brahman.semantic_dht",
+            "provides": ["Spawn", "Journal"],
+            "requires": [],
+            "permissions": {
+                "networking": "loopback",
+                "filesystem": "read-only",
+                "ipc": { "allow": ["wit-v1"] },
+                "processes": false
+            },
+            "soma": {
+                "namespaces": {
+                    "mount": false, "pid": false, "net": false,
+                    "uts": false, "ipc": false, "user": false, "cgroup": false
+                },
+                "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
+                "cgroup": { "path": "ente.slice/dht", "cpu_weight": null, "io_weight": null },
+                "cpu_affinity": null
+            },
+            "payload": "Virtual",
+            "supervision": { "Restart": { "initial": 100, "max": 30000 } },
+            "lifecycle": "daemon",
+            "priority": "high",
+            "flow": {
+                "input": [
+                    { "name": "search-query", "type": { "kind": "primitive", "name": "string" } }
+                ],
+                "output": [
+                    { "name": "dht-results",
+                      "type": { "kind": "wit", "package": "brahman:dht", "name": "entity-result" } }
+                ]
+            },
+            "genesis": []
+        }"#
+    }
+
+    #[test]
+    fn parses_full_json() {
+        let c = Card::from_json(sample_card_json()).unwrap();
+        assert_eq!(c.label, "brahman.semantic_dht");
+        assert_eq!(c.lifecycle, Lifecycle::Daemon);
+        assert_eq!(c.priority, Priority::High);
+        assert_eq!(c.permissions.filesystem, FsPolicy::ReadOnly);
+        assert_eq!(c.permissions.networking, NetworkingPolicy::Loopback);
+        assert_eq!(c.permissions.ipc.allow, vec!["wit-v1".to_string()]);
+        assert_eq!(c.flow.input.len(), 1);
+        assert_eq!(c.flow.output.len(), 1);
+        match &c.flow.output[0].ty {
+            TypeRef::Wit { package, name, .. } => {
+                assert_eq!(package, "brahman:dht");
+                assert_eq!(name, "entity-result");
+            }
+            _ => panic!("expected Wit"),
+        }
+    }
+
+    #[test]
+    fn json_roundtrip_preserves_shape() {
+        let c1 = Card::from_json(sample_card_json()).unwrap();
+        let s = c1.to_json_pretty().unwrap();
+        let c2 = Card::from_json(&s).unwrap();
+        assert_eq!(c1.label, c2.label);
+        assert_eq!(c1.flow.input.len(), c2.flow.input.len());
+    }
+
+    #[test]
+    fn trust_derivation() {
+        let mut p = Permissions::default();
+        assert_eq!(TrustLevel::derive(&p), TrustLevel::Untrusted);
+        p.filesystem = FsPolicy::ReadOnly;
+        assert_eq!(TrustLevel::derive(&p), TrustLevel::Sandboxed);
+        p.networking = NetworkingPolicy::Outbound;
+        assert_eq!(TrustLevel::derive(&p), TrustLevel::Privileged);
+        p.processes = true;
+        assert_eq!(TrustLevel::derive(&p), TrustLevel::System);
+    }
+
+    #[test]
+    fn duplicate_flow_names_rejected() {
+        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
+        c.flow.input.push(c.flow.input[0].clone());
+        assert!(matches!(
+            c.validate(),
+            Err(CardError::DuplicateFlowName { .. })
+        ));
+    }
+
+    #[test]
+    fn self_dependency_rejected() {
+        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
+        c.requires.insert(Capability::Spawn);
+        assert!(matches!(c.validate(), Err(CardError::SelfDependency(_))));
+    }
+
+    #[test]
+    fn invalid_genesis_propagates() {
+        let parent_src = r#"{
+            "schema_version": 1,
+            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
+            "label": "parent",
+            "provides": [], "requires": [],
+            "soma": {
+                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
+                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
+                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
+                "cpu_affinity": null
+            },
+            "payload": "Virtual",
+            "supervision": "OneShot",
+            "genesis": [{
+                "schema_version": 1,
+                "id": "01HQAR53D4M2NBV8KZTYXFGS02",
+                "label": "",
+                "provides": [], "requires": [],
+                "soma": {
+                    "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
+                    "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
+                    "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
+                    "cpu_affinity": null
+                },
+                "payload": "Virtual",
+                "supervision": "OneShot",
+                "genesis": []
+            }]
+        }"#;
+        assert!(matches!(
+            Card::from_json(parent_src),
+            Err(CardError::EmptyLabel)
+        ));
+    }
+
+    #[test]
+    fn presentation_card_carries_derived_trust() {
+        let c = Card::from_json(sample_card_json()).unwrap();
+        let resolved = ResolvedCard::from_agnostic(c);
+        assert_eq!(resolved.trust, TrustLevel::Sandboxed);
+        assert!(resolved.wit.is_none());
+    }
+
+    #[test]
+    fn arje_seed_format_compatible() {
+        // Reproduce el formato canónico de arje (sin lifecycle/priority/flow,
+        // que son aditivos brahman). Debe parsear con defaults.
+        let src = r#"{
+            "schema_version": 1,
+            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
+            "lineage": null,
+            "label": "vps-min",
+            "provides": ["Spawn", "Journal"],
+            "requires": [],
+            "soma": {
+                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
+                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
+                "cgroup": {"path":"ente.slice/zero","cpu_weight":null,"io_weight":null},
+                "cpu_affinity": null
+            },
+            "payload": "Virtual",
+            "supervision": "OneShot",
+            "genesis": []
+        }"#;
+        let c = Card::from_json(src).unwrap();
+        assert_eq!(c.lifecycle, Lifecycle::Daemon); // default
+        assert_eq!(c.priority, Priority::Normal); // default
+        assert_eq!(c.flow.input.len(), 0);
+    }
+}