gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

feat(nakui): módulo crm — clientes, pipeline de ventas, interacciones

Módulo CRM declarativo (schema.ncl + nsmc.json + morfismos Rhai) con
tres entities (Cliente, Oportunidad, Interaccion) y tres morfismos:
abrir_oportunidad, mover_oportunidad (pipeline con validación de
transiciones) y registrar_interaccion.

crm_demo: demo realista de 18 eventos que —a diferencia de los otros
demos— conserva el event log e imprime el comando de nakui-explorer,
así el explorador muestra un CRM con cuerpo. tests/crm.rs: 8 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

78fbde12b4b118275c300ee1be8c370ee3a0c03b · por sergio · (hace 5 meses) · padre bb21c28eb · ver el árbol en este commit

Delta semántico · 46

crates/modules/nakui/core/src/bin/crm_demo.rs

crates/modules/nakui/core/src/bin/nakui.rs

crates/modules/nakui/core/src/delta.rs

crates/modules/nakui/core/src/drift.rs

crates/modules/nakui/core/src/event_log.rs

crates/modules/nakui/core/src/manifest.rs

crates/modules/nakui/core/src/run.rs

crates/modules/nakui/core/src/store.rs

crates/modules/nakui/core/src/surreal_store.rs

crates/modules/nakui/core/tests/kernel_guards.rs

crates/modules/nakui/core/tests/manifest_validation.rs

crates/modules/nakui/core/tests/schema_versioning.rs

crates/modules/nakui/core/tests/snapshot_chain.rs

crates/modules/nakui/core/tests/state_hash.rs

crates/modules/nakui/core/tests/surreal_store.rs

38 archivos · +1229 −334

diff --git a/crates/modules/nakui/core/Cargo.toml b/crates/modules/nakui/core/Cargo.toml
index 1dce843..48d3073 100644
--- a/crates/modules/nakui/core/Cargo.toml
+++ b/crates/modules/nakui/core/Cargo.toml
@@ -56,3 +56,7 @@ path = "src/bin/inventory_demo.rs"
 [[bin]]
 name = "sales_demo"
 path = "src/bin/sales_demo.rs"
+
+[[bin]]
+name = "crm_demo"
+path = "src/bin/crm_demo.rs"
diff --git a/crates/modules/nakui/core/src/bin/crm_demo.rs b/crates/modules/nakui/core/src/bin/crm_demo.rs
new file mode 100644
index 0000000..f56bd31
--- /dev/null
+++ b/crates/modules/nakui/core/src/bin/crm_demo.rs
@@ -0,0 +1,307 @@
+//! Demo del módulo `crm`: un escenario realista — tres clientes, sus
+//! oportunidades recorriendo el pipeline de ventas, e interacciones.
+//!
+//! A diferencia de los otros demos, **no borra el event log**: lo deja
+//! en disco para que `nakui-explorer` lo muestre. Al terminar imprime
+//! el comando exacto para abrir el explorador sobre este log.
+//!
+//! ```sh
+//! cargo run -p nakui-core --bin crm_demo
+//! # …luego, con la ruta que imprime:
+//! NAKUI_EVENT_LOG=/tmp/nakui-crm.jsonl cargo run -p nakui-explorer
+//! ```
+
+use std::path::{Path, PathBuf};
+
+use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog, ExecuteError, LogEntry};
+use nakui_core::executor::Executor;
+use nakui_core::store::{MemoryStore, Store};
+use serde_json::json;
+use uuid::Uuid;
+
+const TS: &str = "2026-05-21T12:00:00Z";
+
+fn main() {
+    let module_dir = std::env::var("NAKUI_MODULE")
+        .map(PathBuf::from)
+        .unwrap_or_else(|_| {
+            Path::new(env!("CARGO_MANIFEST_DIR"))
+                .parent()
+                .expect("dir del módulo nakui sobre core/")
+                .join("modules/crm")
+        });
+    let exec = Executor::load_module(&module_dir).expect("cargar el módulo crm");
+
+    let log_path = std::env::var("NAKUI_EVENT_LOG")
+        .map(PathBuf::from)
+        .unwrap_or_else(|_| std::env::temp_dir().join("nakui-crm.jsonl"));
+    let _ = std::fs::remove_file(&log_path); // empezar de cero
+    let mut log = EventLog::open(&log_path).expect("abrir el event log");
+    let mut store = MemoryStore::new();
+
+    // --- Seed: tres clientes -------------------------------------------
+    section("seed · 3 clientes");
+    let acme = Uuid::new_v4();
+    let beta = Uuid::new_v4();
+    let gamma = Uuid::new_v4();
+    seed_cliente(
+        &exec,
+        &mut store,
+        &mut log,
+        acme,
+        "Acme Corp",
+        "compras@acme.com",
+    );
+    seed_cliente(&exec, &mut store, &mut log, beta, "Beta SA", "ti@beta.com");
+    seed_cliente(
+        &exec,
+        &mut store,
+        &mut log,
+        gamma,
+        "Gamma Ltda",
+        "ceo@gamma.com",
+    );
+
+    // --- Acme: una oportunidad que se gana -----------------------------
+    section("Acme · «Licencia anual» $12 000 — recorre el pipeline");
+    let opp_acme = Uuid::new_v4();
+    abrir(
+        &exec,
+        &mut store,
+        &mut log,
+        acme,
+        opp_acme,
+        "Licencia anual",
+        12_000,
+    );
+    interaccion(
+        &exec,
+        &mut store,
+        &mut log,
+        acme,
+        "llamada",
+        "Primer contacto, interés alto",
+    );
+    for etapa in ["calificado", "propuesta", "negociacion", "ganada"] {
+        mover(&exec, &mut store, &mut log, opp_acme, etapa);
+    }
+    interaccion(
+        &exec,
+        &mut store,
+        &mut log,
+        acme,
+        "email",
+        "Contrato firmado recibido",
+    );
+
+    // --- Beta: una oportunidad que se pierde ---------------------------
+    section("Beta · «Piloto trimestral» $3 000 — se pierde");
+    let opp_beta = Uuid::new_v4();
+    abrir(
+        &exec,
+        &mut store,
+        &mut log,
+        beta,
+        opp_beta,
+        "Piloto trimestral",
+        3_000,
+    );
+    interaccion(
+        &exec,
+        &mut store,
+        &mut log,
+        beta,
+        "reunion",
+        "Demo en sus oficinas",
+    );
+    mover(&exec, &mut store, &mut log, opp_beta, "calificado");
+    mover(&exec, &mut store, &mut log, opp_beta, "propuesta");
+    mover(&exec, &mut store, &mut log, opp_beta, "perdida");
+
+    // --- Gamma: una oportunidad en curso -------------------------------
+    section("Gamma · «Expansión regional» $25 000 — en curso");
+    let opp_gamma = Uuid::new_v4();
+    abrir(
+        &exec,
+        &mut store,
+        &mut log,
+        gamma,
+        opp_gamma,
+        "Expansión regional",
+        25_000,
+    );
+    mover(&exec, &mut store, &mut log, opp_gamma, "calificado");
+    interaccion(
+        &exec,
+        &mut store,
+        &mut log,
+        gamma,
+        "llamada",
+        "Pidieron referencias",
+    );
+
+    // --- Operaciones inválidas: el kernel las rechaza, no se loguean ---
+    section("validaciones · estas operaciones se rechazan");
+    mover(&exec, &mut store, &mut log, opp_acme, "propuesta"); // ya cerrada
+    mover(&exec, &mut store, &mut log, opp_gamma, "prospecto"); // retroceso
+    abrir(
+        &exec,
+        &mut store,
+        &mut log,
+        gamma,
+        Uuid::new_v4(),
+        "Trato inválido",
+        -500,
+    );
+    interaccion(
+        &exec,
+        &mut store,
+        &mut log,
+        gamma,
+        "paloma",
+        "canal inexistente",
+    );
+
+    // --- Estado final --------------------------------------------------
+    section("estado final · oportunidades");
+    print_oportunidad(&store, "Acme ", opp_acme);
+    print_oportunidad(&store, "Beta ", opp_beta);
+    print_oportunidad(&store, "Gamma", opp_gamma);
+
+    let entries = log.entries().expect("leer el log");
+    let seeds = entries
+        .iter()
+        .filter(|e| matches!(e, LogEntry::Seed { .. }))
+        .count();
+    let morphs = entries.len() - seeds;
+    section(&format!(
+        "log · {} eventos ({seeds} seeds, {morphs} morfismos)",
+        entries.len()
+    ));
+    println!("  archivo: {}", log_path.display());
+    println!();
+    println!("para ver el módulo CRM en el explorador:");
+    println!(
+        "  NAKUI_EVENT_LOG={} cargo run -p nakui-explorer",
+        log_path.display()
+    );
+}
+
+fn seed_cliente(
+    exec: &Executor,
+    store: &mut MemoryStore,
+    log: &mut EventLog,
+    id: Uuid,
+    nombre: &str,
+    email: &str,
+) {
+    seed_and_log(
+        exec,
+        store,
+        log,
+        "Cliente",
+        id,
+        json!({
+            "id": id.to_string(),
+            "nombre": nombre,
+            "email": email,
+            "empresa": nombre,
+        }),
+    )
+    .unwrap_or_else(|e| panic!("seed cliente {nombre}: {e}"));
+    println!("  ok · cliente {nombre}");
+}
+
+fn abrir(
+    exec: &Executor,
+    store: &mut MemoryStore,
+    log: &mut EventLog,
+    cliente: Uuid,
+    opp: Uuid,
+    titulo: &str,
+    monto: i64,
+) {
+    report(
+        &format!("abrir_oportunidad «{titulo}»"),
+        execute_and_log(
+            exec,
+            store,
+            log,
+            "abrir_oportunidad",
+            &[("cliente", cliente)],
+            json!({
+                "oportunidad_id": opp.to_string(),
+                "titulo": titulo,
+                "monto": monto,
+                "currency": "USD",
+                "timestamp": TS,
+            }),
+        ),
+    );
+}
+
+fn mover(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, opp: Uuid, destino: &str) {
+    report(
+        &format!("mover_oportunidad → {destino}"),
+        execute_and_log(
+            exec,
+            store,
+            log,
+            "mover_oportunidad",
+            &[("oportunidad", opp)],
+            json!({ "etapa": destino, "timestamp": TS }),
+        ),
+    );
+}
+
+fn interaccion(
+    exec: &Executor,
+    store: &mut MemoryStore,
+    log: &mut EventLog,
+    cliente: Uuid,
+    canal: &str,
+    nota: &str,
+) {
+    report(
+        &format!("registrar_interaccion ({canal})"),
+        execute_and_log(
+            exec,
+            store,
+            log,
+            "registrar_interaccion",
+            &[("cliente", cliente)],
+            json!({
+                "interaccion_id": Uuid::new_v4().to_string(),
+                "canal": canal,
+                "nota": nota,
+                "timestamp": TS,
+            }),
+        ),
+    );
+}
+
+/// Reporta el resultado de un morfismo. Genérico sobre el tipo de op
+/// para no exponer el tipo interno del executor.
+fn report<T>(label: &str, result: Result<Vec<T>, ExecuteError>) {
+    match result {
+        Ok(ops) => println!("  ok · {label} ({} ops)", ops.len()),
+        Err(ExecuteError::PreLog(e)) => println!("  rechazado · {label}: {e}"),
+        Err(e) => println!("  ERROR · {label}: {e:?}"),
+    }
+}
+
+fn print_oportunidad(store: &MemoryStore, etiqueta: &str, id: Uuid) {
+    match store.load("Oportunidad", id) {
+        Some(v) => {
+            let titulo = v.get("titulo").and_then(|x| x.as_str()).unwrap_or("?");
+            let etapa = v.get("etapa").and_then(|x| x.as_str()).unwrap_or("?");
+            let monto = v.get("monto").and_then(|x| x.as_i64()).unwrap_or(0);
+            println!("  {etiqueta} · {titulo} — ${monto} — etapa: {etapa}");
+        }
+        None => println!("  {etiqueta} · (sin oportunidad)"),
+    }
+}
+
+fn section(title: &str) {
+    println!("\n— {title}");
+}
diff --git a/crates/modules/nakui/core/src/bin/demo.rs b/crates/modules/nakui/core/src/bin/demo.rs
index 93c9254..8abf9df 100644
--- a/crates/modules/nakui/core/src/bin/demo.rs
+++ b/crates/modules/nakui/core/src/bin/demo.rs
@@ -1,5 +1,5 @@
 use nakui_core::event_log::{
-    EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
+    execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
@@ -7,8 +7,7 @@ use serde_json::json;
 use uuid::Uuid;
 
 fn main() {
-    let module_dir =
-        std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
+    let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
     let exec = Executor::load_module(&module_dir).expect("load module");
 
     let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4()));
@@ -162,10 +161,7 @@ fn main() {
                 seq, entity, id, ..
             } => println!("  #{:02} seed   {} {}", seq, entity, id),
             nakui_core::event_log::LogEntry::Morphism {
-                seq,
-                morphism,
-                ops,
-                ..
+                seq, morphism, ops, ..
             } => println!("  #{:02} morph  {} ({} ops)", seq, morphism, ops.len()),
         }
     }
@@ -180,16 +176,17 @@ fn main() {
 
     section("== determinism verification (ops) ==");
     match verify_log(&log, &exec) {
-        Ok(()) => println!(
-            "  ok: every logged morphism reproduced its ops on re-execution"
-        ),
+        Ok(()) => println!("  ok: every logged morphism reproduced its ops on re-execution"),
         Err(e) => println!("  nondeterminism detected: {}", e),
     }
 
     if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
         let _ = std::fs::remove_file(&log_path);
     } else {
-        println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display());
+        println!(
+            "\n(NAKUI_DEMO_KEEP set — keeping log at {})",
+            log_path.display()
+        );
     }
 }
 
@@ -202,7 +199,11 @@ fn run_and_report(
     params: serde_json::Value,
 ) {
     match execute_and_log(exec, store, log, morphism, inputs, params) {
-        Ok(ops) => println!("  ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
+        Ok(ops) => println!(
+            "  ok ({} ops, logged at #{})",
+            ops.len(),
+            log.next_seq() - 1
+        ),
         Err(ExecuteError::PreLog(e)) => println!("  rejected: {}", e),
         Err(ExecuteError::LogAppend(e)) => println!("  LOG APPEND FAILED: {}", e),
         Err(ExecuteError::PostLogStore(e)) => println!(
diff --git a/crates/modules/nakui/core/src/bin/inventory_demo.rs b/crates/modules/nakui/core/src/bin/inventory_demo.rs
index 606a556..bdfa801 100644
--- a/crates/modules/nakui/core/src/bin/inventory_demo.rs
+++ b/crates/modules/nakui/core/src/bin/inventory_demo.rs
@@ -1,5 +1,5 @@
 use nakui_core::event_log::{
-    EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
+    execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
@@ -7,12 +7,10 @@ use serde_json::json;
 use uuid::Uuid;
 
 fn main() {
-    let module_dir = std::env::var("NAKUI_MODULE")
-        .unwrap_or_else(|_| "modules/inventory".into());
+    let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/inventory".into());
     let exec = Executor::load_module(&module_dir).expect("load module");
 
-    let log_path =
-        std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
+    let log_path = std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
     let mut log = EventLog::open(&log_path).expect("open log");
     let mut store = MemoryStore::new();
 
@@ -23,34 +21,46 @@ fn main() {
     let stock_c = Uuid::new_v4();
     seed_and_log(
         &exec,
-        &mut store, &mut log, "Stock", stock_a,
+        &mut store,
+        &mut log,
+        "Stock",
+        stock_a,
         json!({
             "id": stock_a.to_string(),
             "sku_id": "kg-cafe-honduras-2026",
             "ubicacion": "almacen-norte",
             "cantidad": 500_i64,
         }),
-    ).expect("seed A");
+    )
+    .expect("seed A");
     seed_and_log(
         &exec,
-        &mut store, &mut log, "Stock", stock_b,
+        &mut store,
+        &mut log,
+        "Stock",
+        stock_b,
         json!({
             "id": stock_b.to_string(),
             "sku_id": "kg-cafe-honduras-2026",
             "ubicacion": "almacen-sur",
             "cantidad": 100_i64,
         }),
-    ).expect("seed B");
+    )
+    .expect("seed B");
     seed_and_log(
         &exec,
-        &mut store, &mut log, "Stock", stock_c,
+        &mut store,
+        &mut log,
+        "Stock",
+        stock_c,
         json!({
             "id": stock_c.to_string(),
             "sku_id": "lt-aceite-girasol",
             "ubicacion": "almacen-sur",
             "cantidad": 200_i64,
         }),
-    ).expect("seed C");
+    )
+    .expect("seed C");
 
     section("== seed ==");
     print_stock(&store, "A (cafe norte)", stock_a);
@@ -58,7 +68,11 @@ fn main() {
     print_stock(&store, "C (aceite sur)", stock_c);
 
     section("== recibir 250 kg cafe en A ==");
-    run_and_report(&exec, &mut store, &mut log, "recibir_stock",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "recibir_stock",
         &[("stock", stock_a)],
         json!({
             "cantidad": 250_i64,
@@ -69,7 +83,11 @@ fn main() {
     print_stock(&store, "A", stock_a);
 
     section("== transferir 200 kg cafe A -> B (conserva por sku_id) ==");
-    run_and_report(&exec, &mut store, &mut log, "transferir_stock",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "transferir_stock",
         &[("source", stock_a), ("dest", stock_b)],
         json!({
             "cantidad": 200_i64,
@@ -81,7 +99,11 @@ fn main() {
     print_stock(&store, "B", stock_b);
 
     section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) ==");
-    run_and_report(&exec, &mut store, &mut log, "transferir_stock",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "transferir_stock",
         &[("source", stock_a), ("dest", stock_b)],
         json!({
             "cantidad": 999_999_i64,
@@ -91,7 +113,11 @@ fn main() {
     );
 
     section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) ==");
-    run_and_report(&exec, &mut store, &mut log, "transferir_stock",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "transferir_stock",
         &[("source", stock_a), ("dest", stock_c)],
         json!({
             "cantidad": 50_i64,
@@ -113,10 +139,12 @@ fn main() {
     ));
     for e in &entries {
         match e {
-            nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
-                println!("  #{:02} seed   {} {}", seq, entity, id),
-            nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
-                println!("  #{:02} morph  {} ({} ops)", seq, morphism, ops.len()),
+            nakui_core::event_log::LogEntry::Seed {
+                seq, entity, id, ..
+            } => println!("  #{:02} seed   {} {}", seq, entity, id),
+            nakui_core::event_log::LogEntry::Morphism {
+                seq, morphism, ops, ..
+            } => println!("  #{:02} morph  {} ({} ops)", seq, morphism, ops.len()),
         }
     }
 
@@ -130,9 +158,7 @@ fn main() {
 
     section("== determinism verification (ops) ==");
     match verify_log(&log, &exec) {
-        Ok(()) => println!(
-            "  ok: every logged morphism reproduced its ops on re-execution"
-        ),
+        Ok(()) => println!("  ok: every logged morphism reproduced its ops on re-execution"),
         Err(e) => println!("  nondeterminism detected: {}", e),
     }
 
@@ -148,11 +174,16 @@ fn run_and_report(
     params: serde_json::Value,
 ) {
     match execute_and_log(exec, store, log, morphism, inputs, params) {
-        Ok(ops) => println!("  ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
+        Ok(ops) => println!(
+            "  ok ({} ops, logged at #{})",
+            ops.len(),
+            log.next_seq() - 1
+        ),
         Err(ExecuteError::PreLog(e)) => println!("  rejected: {}", e),
         Err(ExecuteError::LogAppend(e)) => println!("  LOG APPEND FAILED: {}", e),
         Err(ExecuteError::PostLogStore(e)) => println!(
-            "  POST-LOG STORE FAILED (log canonical, store stale): {}", e
+            "  POST-LOG STORE FAILED (log canonical, store stale): {}",
+            e
         ),
     }
 }
@@ -162,7 +193,10 @@ fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
     let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0);
     let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?");
     let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?");
-    println!("  {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc);
+    println!(
+        "  {}: cantidad={} sku={} ubic={}",
+        label, cantidad, sku, loc
+    );
 }
 
 fn section(title: &str) {
diff --git a/crates/modules/nakui/core/src/bin/nakui.rs b/crates/modules/nakui/core/src/bin/nakui.rs
index a3fb212..0360a97 100644
--- a/crates/modules/nakui/core/src/bin/nakui.rs
+++ b/crates/modules/nakui/core/src/bin/nakui.rs
@@ -13,10 +13,8 @@ use std::collections::BTreeMap;
 use std::path::PathBuf;
 use std::process::ExitCode;
 
-use nakui_core::drift::{DriftDiff, check_against_socket};
-use nakui_core::event_log::{
-    EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log,
-};
+use nakui_core::drift::{check_against_socket, DriftDiff};
+use nakui_core::event_log::{replay_with_snapshot_into, verify_log, EventLog, LogEntry, Snapshot};
 use nakui_core::executor::Executor;
 use nakui_core::run::run_server;
 use nakui_core::store::MemoryStore;
@@ -111,19 +109,16 @@ fn parse_flags(args: &[String], allowed: &[&str]) -> Result<BTreeMap<String, Str
         if !allowed.contains(&name) {
             return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
         }
-        let val = args.get(i + 1).ok_or_else(|| {
-            CliError::BadArgs(format!("flag `--{}` requires a value", name))
-        })?;
+        let val = args
+            .get(i + 1)
+            .ok_or_else(|| CliError::BadArgs(format!("flag `--{}` requires a value", name)))?;
         out.insert(name.to_string(), val.clone());
         i += 2;
     }
     Ok(out)
 }
 
-fn require<'a>(
-    flags: &'a BTreeMap<String, String>,
-    name: &str,
-) -> Result<&'a String, CliError> {
+fn require<'a>(flags: &'a BTreeMap<String, String>, name: &str) -> Result<&'a String, CliError> {
     flags
         .get(name)
         .ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name)))
@@ -141,7 +136,11 @@ fn cmd_inspect(args: &[String]) -> Result<(), CliError> {
     if entries.is_empty() {
         return Ok(());
     }
-    println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq());
+    println!(
+        "seq range: {}..={}",
+        entries[0].seq(),
+        entries.last().unwrap().seq()
+    );
     println!();
     for e in &entries {
         match e {
@@ -195,18 +194,18 @@ fn cmd_replay(args: &[String]) -> Result<(), CliError> {
     let entries = log
         .entries()
         .map_err(|e| CliError::Op(format!("read log: {}", e)))?;
-    let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "<empty>".into());
+    let last_seq = entries
+        .last()
+        .map(|e| e.seq().to_string())
+        .unwrap_or_else(|| "<empty>".into());
     println!("replayed log: {}", log.path().display());
     if let Some(snap) = &snapshot {
         println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
     }
     println!("last seq: {}", last_seq);
     println!("entities:");
-    let mut by_entity: Vec<(&String, usize)> = store
-        .records()
-        .iter()
-        .map(|(k, v)| (k, v.len()))
-        .collect();
+    let mut by_entity: Vec<(&String, usize)> =
+        store.records().iter().map(|(k, v)| (k, v.len())).collect();
     by_entity.sort_by(|a, b| a.0.cmp(b.0));
     if by_entity.is_empty() {
         println!("  (none)");
@@ -414,7 +413,8 @@ fn cmd_compact(args: &[String]) -> Result<(), CliError> {
     let snap = Snapshot::load(&snap_path)
         .map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
         .ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?;
-    let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
+    let mut log =
+        EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
     let before = log
         .entries()
         .map(|es| es.len())
diff --git a/crates/modules/nakui/core/src/bin/sales_demo.rs b/crates/modules/nakui/core/src/bin/sales_demo.rs
index d7a3946..9bb6d9f 100644
--- a/crates/modules/nakui/core/src/bin/sales_demo.rs
+++ b/crates/modules/nakui/core/src/bin/sales_demo.rs
@@ -5,7 +5,7 @@
 //! against all three.
 
 use nakui_core::event_log::{
-    EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
+    execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
@@ -13,12 +13,10 @@ use serde_json::json;
 use uuid::Uuid;
 
 fn main() {
-    let module_dir = std::env::var("NAKUI_MODULE")
-        .unwrap_or_else(|_| "modules/sales".into());
+    let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/sales".into());
     let exec = Executor::load_module(&module_dir).expect("load module");
 
-    let log_path =
-        std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
+    let log_path = std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
     let mut log = EventLog::open(&log_path).expect("open log");
     let mut store = MemoryStore::new();
 
@@ -26,24 +24,32 @@ fn main() {
     let caja_id = Uuid::new_v4();
     seed_and_log(
         &exec,
-        &mut store, &mut log, "Stock", stock_id,
+        &mut store,
+        &mut log,
+        "Stock",
+        stock_id,
         json!({
             "id": stock_id.to_string(),
             "sku_id": "kg-cafe-honduras-2026",
             "ubicacion": "almacen-norte",
             "cantidad": 500_i64,
         }),
-    ).expect("seed stock");
+    )
+    .expect("seed stock");
     seed_and_log(
         &exec,
-        &mut store, &mut log, "Caja", caja_id,
+        &mut store,
+        &mut log,
+        "Caja",
+        caja_id,
         json!({
             "id": caja_id.to_string(),
             "name": "Caja Principal",
             "saldo": 1_000_000_i64,  // $10_000.00 in cents
             "currency": "USD",
         }),
-    ).expect("seed caja");
+    )
+    .expect("seed caja");
 
     section("== seed ==");
     print_stock(&store, "stock", stock_id);
@@ -51,7 +57,11 @@ fn main() {
 
     // 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
     section("== vender 100 kg @ $50.00 c/u ==");
-    run_and_report(&exec, &mut store, &mut log, "vender",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "vender",
         &[("stock", stock_id), ("caja", caja_id)],
         json!({
             "cantidad": 100_i64,
@@ -65,7 +75,11 @@ fn main() {
 
     // 2. Try selling more than available stock — should fail Stock post-check.
     section("== vender 9999 kg (reject: stock <= 0) ==");
-    run_and_report(&exec, &mut store, &mut log, "vender",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "vender",
         &[("stock", stock_id), ("caja", caja_id)],
         json!({
             "cantidad": 9999_i64,
@@ -77,7 +91,11 @@ fn main() {
 
     // 3. Negative price — caught by Rhai.
     section("== vender con precio negativo (reject: rhai throw) ==");
-    run_and_report(&exec, &mut store, &mut log, "vender",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "vender",
         &[("stock", stock_id), ("caja", caja_id)],
         json!({
             "cantidad": 10_i64,
@@ -89,7 +107,11 @@ fn main() {
 
     // 4. Another good sale.
     section("== vender 50 kg @ $60.00 c/u ==");
-    run_and_report(&exec, &mut store, &mut log, "vender",
+    run_and_report(
+        &exec,
+        &mut store,
+        &mut log,
+        "vender",
         &[("stock", stock_id), ("caja", caja_id)],
         json!({
             "cantidad": 50_i64,
@@ -113,10 +135,12 @@ fn main() {
     ));
     for e in &entries {
         match e {
-            nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
-                println!("  #{:02} seed   {} {}", seq, entity, id),
-            nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
-                println!("  #{:02} morph  {} ({} ops)", seq, morphism, ops.len()),
+            nakui_core::event_log::LogEntry::Seed {
+                seq, entity, id, ..
+            } => println!("  #{:02} seed   {} {}", seq, entity, id),
+            nakui_core::event_log::LogEntry::Morphism {
+                seq, morphism, ops, ..
+            } => println!("  #{:02} morph  {} ({} ops)", seq, morphism, ops.len()),
         }
     }
 
@@ -130,9 +154,7 @@ fn main() {
 
     section("== determinism verification (ops) ==");
     match verify_log(&log, &exec) {
-        Ok(()) => println!(
-            "  ok: every logged morphism reproduced its ops on re-execution"
-        ),
+        Ok(()) => println!("  ok: every logged morphism reproduced its ops on re-execution"),
         Err(e) => println!("  nondeterminism detected: {}", e),
     }
 
@@ -148,11 +170,16 @@ fn run_and_report(
     params: serde_json::Value,
 ) {
     match execute_and_log(exec, store, log, morphism, inputs, params) {
-        Ok(ops) => println!("  ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
+        Ok(ops) => println!(
+            "  ok ({} ops, logged at #{})",
+            ops.len(),
+            log.next_seq() - 1
+        ),
         Err(ExecuteError::PreLog(e)) => println!("  rejected: {}", e),
         Err(ExecuteError::LogAppend(e)) => println!("  LOG APPEND FAILED: {}", e),
         Err(ExecuteError::PostLogStore(e)) => println!(
-            "  POST-LOG STORE FAILED (log canonical, store stale): {}", e
+            "  POST-LOG STORE FAILED (log canonical, store stale): {}",
+            e
         ),
     }
 }
diff --git a/crates/modules/nakui/core/src/delta.rs b/crates/modules/nakui/core/src/delta.rs
index 2cdf6cc..54c07b9 100644
--- a/crates/modules/nakui/core/src/delta.rs
+++ b/crates/modules/nakui/core/src/delta.rs
@@ -150,10 +150,7 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op
             } if e == entity && *i == id => {
                 s = Some(data.clone());
             }
-            FieldOp::Delete {
-                entity: e,
-                id: i,
-            } if e == entity && *i == id => {
+            FieldOp::Delete { entity: e, id: i } if e == entity && *i == id => {
                 s = None;
             }
             _ => {}
diff --git a/crates/modules/nakui/core/src/drift.rs b/crates/modules/nakui/core/src/drift.rs
index 884ec6f..92b60ef 100644
--- a/crates/modules/nakui/core/src/drift.rs
+++ b/crates/modules/nakui/core/src/drift.rs
@@ -19,7 +19,7 @@ use std::path::Path;
 use thiserror::Error;
 use uuid::Uuid;
 
-use crate::event_log::{EventLog, replay};
+use crate::event_log::{replay, EventLog};
 use crate::store::Store;
 
 /// A single record-level difference between two snapshots. Variants are
@@ -333,13 +333,13 @@ fn parse_records(resp: &Value) -> Result<Vec<(String, Uuid, Value)>, DriftError>
                 field: "records[].entity".into(),
             })?
             .to_string();
-        let id_str = item
-            .get("id")
-            .and_then(Value::as_str)
-            .ok_or_else(|| DriftError::MissingField {
-                op: "dump_records".into(),
-                field: "records[].id".into(),
-            })?;
+        let id_str =
+            item.get("id")
+                .and_then(Value::as_str)
+                .ok_or_else(|| DriftError::MissingField {
+                    op: "dump_records".into(),
+                    field: "records[].id".into(),
+                })?;
         let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField {
             op: "dump_records".into(),
             field: format!("records[].id (not uuid: {})", id_str),
@@ -377,16 +377,8 @@ mod tests {
         // The function compares records, not hashes — hash equality is
         // the operator's fast-path, but the report's truth is the diffs.
         let a = Uuid::new_v4();
-        let log = vec![(
-            "Caja".to_string(),
-            a,
-            json!({"saldo": 100}),
-        )];
-        let server = vec![(
-            "Caja".to_string(),
-            a,
-            json!({"saldo": 100}),
-        )];
+        let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
+        let server = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
         let report = compare_states(log, h(1), server, h(2));
         assert!(report.diffs.is_empty(), "records equal → no diffs");
     }
@@ -395,11 +387,7 @@ mod tests {
     fn detects_only_on_server() {
         let a = Uuid::new_v4();
         let b = Uuid::new_v4();
-        let log = vec![(
-            "Caja".to_string(),
-            a,
-            json!({"saldo": 100}),
-        )];
+        let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
         let server = vec![
             ("Caja".to_string(), a, json!({"saldo": 100})),
             ("Caja".to_string(), b, json!({"saldo": 999})),
@@ -457,17 +445,16 @@ mod tests {
         let id_caja = Uuid::nil(); // sorts first byte-wise
         let id_mov = Uuid::from_u128(u128::MAX);
 
-        let log = vec![
-            ("Movimiento".to_string(), id_mov, json!({"x": 1})),
-        ];
-        let server = vec![
-            ("Caja".to_string(), id_caja, json!({"saldo": 0})),
-        ];
+        let log = vec![("Movimiento".to_string(), id_mov, json!({"x": 1}))];
+        let server = vec![("Caja".to_string(), id_caja, json!({"saldo": 0}))];
         let report = compare_states(log, h(0), server, h(1));
         assert_eq!(report.diffs.len(), 2);
         // Caja sorts before Movimiento.
         match (&report.diffs[0], &report.diffs[1]) {
-            (DriftDiff::OnlyOnServer { entity: e1, .. }, DriftDiff::OnlyInLog { entity: e2, .. }) => {
+            (
+                DriftDiff::OnlyOnServer { entity: e1, .. },
+                DriftDiff::OnlyInLog { entity: e2, .. },
+            ) => {
                 assert_eq!(e1, "Caja");
                 assert_eq!(e2, "Movimiento");
             }
diff --git a/crates/modules/nakui/core/src/event_log.rs b/crates/modules/nakui/core/src/event_log.rs
index 1a3d2f5..dc4bc34 100644
--- a/crates/modules/nakui/core/src/event_log.rs
+++ b/crates/modules/nakui/core/src/event_log.rs
@@ -363,10 +363,7 @@ impl Snapshot {
     /// Verify the snapshot was produced under a bundle compatible with
     /// `executor`. Snapshots without a hash (legacy / `from_memory_store`)
     /// pass — the operator opted out of this check at capture time.
-    pub fn ensure_compatible_with(
-        &self,
-        executor: &Executor,
-    ) -> Result<(), SnapshotMismatchError> {
+    pub fn ensure_compatible_with(&self, executor: &Executor) -> Result<(), SnapshotMismatchError> {
         let Some(snap_hash) = self.schema_hash else {
             return Ok(());
         };
@@ -405,10 +402,8 @@ impl Snapshot {
             return Ok(None);
         }
         let text = std::fs::read_to_string(path).map_err(LogError::Io)?;
-        let snap: Snapshot = serde_json::from_str(&text).map_err(|e| LogError::Parse {
-            line: 0,
-            source: e,
-        })?;
+        let snap: Snapshot =
+            serde_json::from_str(&text).map_err(|e| LogError::Parse { line: 0, source: e })?;
         Ok(Some(snap))
     }
 }
@@ -485,10 +480,7 @@ pub fn execute_and_log<S: Store>(
     let entry = LogEntry::Morphism {
         seq,
         morphism: morphism.to_string(),
-        inputs: inputs
-            .iter()
-            .map(|(r, id)| (r.to_string(), *id))
-            .collect(),
+        inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
         params,
         ops: ops.clone(),
         schema_hash: executor.schema_hash(morphism),
@@ -534,10 +526,7 @@ pub fn execute_and_log_with_recovery<S: Store>(
     let entry = LogEntry::Morphism {
         seq,
         morphism: morphism.to_string(),
-        inputs: inputs
-            .iter()
-            .map(|(r, id)| (r.to_string(), *id))
-            .collect(),
+        inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
         params,
         ops: ops.clone(),
         schema_hash: executor.schema_hash(morphism),
diff --git a/crates/modules/nakui/core/src/executor.rs b/crates/modules/nakui/core/src/executor.rs
index 23ea7d9..ecac475 100644
--- a/crates/modules/nakui/core/src/executor.rs
+++ b/crates/modules/nakui/core/src/executor.rs
@@ -1,14 +1,14 @@
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use sha2::{Digest, Sha256};
 use std::collections::{BTreeMap, HashMap, HashSet};
 use std::path::{Path, PathBuf};
 use thiserror::Error;
 use uuid::Uuid;
 
-use crate::delta::{FieldOp, simulate_on};
+use crate::delta::{simulate_on, FieldOp};
 use crate::graph::{GraphError, ManifestGraph};
-use crate::nickel_validator::{self, NickelError};
 use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
+use crate::nickel_validator::{self, NickelError};
 use crate::rhai_executor::{RhaiError, RhaiExecutor};
 use crate::store::{Store, StoreError};
 
@@ -141,7 +141,8 @@ impl Executor {
         // archivo apuntado se mueve). Sin esto, el bundle hash quedaba
         // pegado y la versión del seed nunca detectaba ediciones de
         // schema. Ver `verify_log_rejects_seed_after_schema_changes`.
-        let schema_bundle_bytes = read_schema_files_concat(&module_dir, &manifest.effective_schemas())?;
+        let schema_bundle_bytes =
+            read_schema_files_concat(&module_dir, &manifest.effective_schemas())?;
         let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
         let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
         for spec in &manifest.morphisms {
@@ -291,20 +292,14 @@ impl Executor {
                         Some(_) => {
                             return Err(ExecError::CapabilityViolation {
                                 morphism: morphism_name.to_string(),
-                                token: format!(
-                                    "<entity-mismatch>.{}.{}",
-                                    path.entity, path.field
-                                ),
+                                token: format!("<entity-mismatch>.{}.{}", path.entity, path.field),
                                 declared: spec.writes.clone(),
                             });
                         }
                         None => {
                             return Err(ExecError::CapabilityViolation {
                                 morphism: morphism_name.to_string(),
-                                token: format!(
-                                    "<untracked id>.{}.{}",
-                                    path.entity, path.field
-                                ),
+                                token: format!("<untracked id>.{}.{}", path.entity, path.field),
                                 declared: spec.writes.clone(),
                             });
                         }
@@ -330,8 +325,7 @@ impl Executor {
         // 7. Per-input KCL post-check; skip Deleted inputs.
         for spec_in in &spec.inputs {
             let id = inputs_map[&spec_in.role];
-            if let Some(new_state) =
-                simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
+            if let Some(new_state) = simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
             {
                 self.validate_entity(&spec_in.entity, &new_state)
                     .map_err(|e| ExecError::SchemaPost {
@@ -598,23 +592,21 @@ fn check_conservation(
                     ),
                 })?;
             let old_state = &loaded[&binding.role];
-            let old_val =
-                old_state
-                    .get(&rule.field)
-                    .and_then(Value::as_i64)
-                    .ok_or_else(|| ExecError::ConservationMalformed {
-                        entity: rule.entity.clone(),
-                        field: rule.field.clone(),
-                        message: format!("old value at role `{}` is not i64", binding.role),
-                    })?;
-            let new_val =
-                value
-                    .as_i64()
-                    .ok_or_else(|| ExecError::ConservationMalformed {
-                        entity: rule.entity.clone(),
-                        field: rule.field.clone(),
-                        message: format!("Set value at role `{}` is not i64", binding.role),
-                    })?;
+            let old_val = old_state
+                .get(&rule.field)
+                .and_then(Value::as_i64)
+                .ok_or_else(|| ExecError::ConservationMalformed {
+                    entity: rule.entity.clone(),
+                    field: rule.field.clone(),
+                    message: format!("old value at role `{}` is not i64", binding.role),
+                })?;
+            let new_val = value
+                .as_i64()
+                .ok_or_else(|| ExecError::ConservationMalformed {
+                    entity: rule.entity.clone(),
+                    field: rule.field.clone(),
+                    message: format!("Set value at role `{}` is not i64", binding.role),
+                })?;
             let group_key = match &rule.group_by {
                 Some(g) => old_state
                     .get(g)
@@ -623,8 +615,7 @@ fn check_conservation(
                     .to_string(),
                 None => String::new(),
             };
-            *delta_by_group.entry(group_key).or_insert(0) +=
-                (new_val as i128) - (old_val as i128);
+            *delta_by_group.entry(group_key).or_insert(0) += (new_val as i128) - (old_val as i128);
         }
     }
 
@@ -673,7 +664,10 @@ let y = 2;
         // content, not cosmetic.
         let src = r#"let s = "hello  // not a comment \"world\"";"#;
         let normalized = normalize_rhai_source(src);
-        assert_eq!(normalized, r#"let s = "hello  // not a comment \"world\"";"#);
+        assert_eq!(
+            normalized,
+            r#"let s = "hello  // not a comment \"world\"";"#
+        );
     }
 
     #[test]
diff --git a/crates/modules/nakui/core/src/graph.rs b/crates/modules/nakui/core/src/graph.rs
index 21dece5..b61062a 100644
--- a/crates/modules/nakui/core/src/graph.rs
+++ b/crates/modules/nakui/core/src/graph.rs
@@ -189,7 +189,10 @@ fn build_data_flow(
         for r in &m.reads {
             if let Some(token) = canonicalize_token(r, &role_to_entity) {
                 if seen_reads.insert(token.clone()) {
-                    readers.entry(token.clone()).or_default().push(m.name.clone());
+                    readers
+                        .entry(token.clone())
+                        .or_default()
+                        .push(m.name.clone());
                     m_reads.entry(m.name.clone()).or_default().push(token);
                 }
             }
@@ -198,7 +201,10 @@ fn build_data_flow(
         for w in &m.writes {
             if let Some(token) = canonicalize_token(w, &role_to_entity) {
                 if seen_writes.insert(token.clone()) {
-                    writers.entry(token.clone()).or_default().push(m.name.clone());
+                    writers
+                        .entry(token.clone())
+                        .or_default()
+                        .push(m.name.clone());
                     m_writes.entry(m.name.clone()).or_default().push(token);
                 }
             }
diff --git a/crates/modules/nakui/core/src/manifest.rs b/crates/modules/nakui/core/src/manifest.rs
index 9ec065f..ecafc81 100644
--- a/crates/modules/nakui/core/src/manifest.rs
+++ b/crates/modules/nakui/core/src/manifest.rs
@@ -150,11 +150,9 @@ impl Manifest {
                     resolved: resolved.display().to_string(),
                 });
             }
-            let content = std::fs::read_to_string(&resolved).map_err(|e| {
-                ValidationError::Io {
-                    path: s.clone(),
-                    source: e,
-                }
+            let content = std::fs::read_to_string(&resolved).map_err(|e| ValidationError::Io {
+                path: s.clone(),
+                source: e,
             })?;
             for name in extract_schema_names(&content) {
                 entity_to_files.entry(name).or_default().push(s.clone());
@@ -168,8 +166,7 @@ impl Manifest {
                 });
             }
         }
-        let known_entities: HashSet<&str> =
-            entity_to_files.keys().map(String::as_str).collect();
+        let known_entities: HashSet<&str> = entity_to_files.keys().map(String::as_str).collect();
 
         // 2. Manifest-level: morphism names must be unique.
         let mut seen: HashSet<&str> = HashSet::new();
diff --git a/crates/modules/nakui/core/src/nickel_validator.rs b/crates/modules/nakui/core/src/nickel_validator.rs
index 06fcec0..ad6512b 100644
--- a/crates/modules/nakui/core/src/nickel_validator.rs
+++ b/crates/modules/nakui/core/src/nickel_validator.rs
@@ -60,8 +60,8 @@ pub fn vet(schema_path: &Path, state: &Value, schema_name: &str) -> Result<(), N
          (std.deserialize 'Json m%%\"{state_json}\"%%) | bundle.{schema_name}"
     );
 
-    let mut ctx = nickel_lang::Context::new()
-        .with_source_name(format!("nakui-validate-{schema_name}"));
+    let mut ctx =
+        nickel_lang::Context::new().with_source_name(format!("nakui-validate-{schema_name}"));
 
     match ctx.eval_deep_for_export(&source) {
         Ok(_) => Ok(()),
@@ -130,7 +130,9 @@ mod tests {
         let state = json!({"id": "abc"}); // falta cantidad
         let err = vet(&schema, &state, "Stock").unwrap_err();
         assert!(matches!(err, NickelError::ValidationFailed(_)));
-        let NickelError::ValidationFailed(msg) = err else { panic!() };
+        let NickelError::ValidationFailed(msg) = err else {
+            panic!()
+        };
         assert!(
             msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"),
             "msg debe mencionar el field missing: {msg}"
diff --git a/crates/modules/nakui/core/src/rhai_executor.rs b/crates/modules/nakui/core/src/rhai_executor.rs
index be41439..8c762d2 100644
--- a/crates/modules/nakui/core/src/rhai_executor.rs
+++ b/crates/modules/nakui/core/src/rhai_executor.rs
@@ -2,7 +2,7 @@ use rhai::packages::{
     ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage,
     BasicStringPackage, CorePackage, LogicPackage, Package,
 };
-use rhai::{AST, Dynamic, Engine, Scope};
+use rhai::{Dynamic, Engine, Scope, AST};
 use serde_json::Value;
 use std::cell::RefCell;
 use std::collections::HashMap;
@@ -76,8 +76,8 @@ impl RhaiExecutor {
         for item in arr {
             let json: Value = rhai::serde::from_dynamic(&item)
                 .map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?;
-            let op: FieldOp = serde_json::from_value(json)
-                .map_err(|e| RhaiError::BadOp(e.to_string()))?;
+            let op: FieldOp =
+                serde_json::from_value(json).map_err(|e| RhaiError::BadOp(e.to_string()))?;
             ops.push(op);
         }
         Ok(ops)
diff --git a/crates/modules/nakui/core/src/run.rs b/crates/modules/nakui/core/src/run.rs
index 672b309..9157aa1 100644
--- a/crates/modules/nakui/core/src/run.rs
+++ b/crates/modules/nakui/core/src/run.rs
@@ -24,13 +24,13 @@ use std::os::unix::net::{UnixListener, UnixStream};
 use std::path::Path;
 
 use serde::Deserialize;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use thiserror::Error;
 use uuid::Uuid;
 
 use crate::event_log::{
-    EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
-    execute_and_log_with_recovery, replay_with_snapshot_into, verify_log,
+    execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, EventLog,
+    RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
 };
 use crate::executor::Executor;
 use crate::store::Store;
@@ -206,7 +206,10 @@ fn handle_connection<S: Store>(
         }
         let (response, shutdown) = dispatch(&line, executor, store, log);
         let bytes = serde_json::to_vec(&response).expect("response serializes");
-        if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.write_all(b"\n")) {
+        if let Err(e) = writer
+            .write_all(&bytes)
+            .and_then(|_| writer.write_all(b"\n"))
+        {
             eprintln!("nakui run: write: {}", e);
             return false;
         }
@@ -291,16 +294,10 @@ fn dispatch<S: Store>(
         }
         Request::Verify => match verify_log(log, executor) {
             Ok(()) => {
-                let entries = log
-                    .entries()
-                    .map(|es| es.len())
-                    .unwrap_or(0);
+                let entries = log.entries().map(|es| es.len()).unwrap_or(0);
                 (json!({"ok": true, "entries": entries}), false)
             }
-            Err(e) => (
-                json!({"ok": false, "error": e.to_string()}),
-                false,
-            ),
+            Err(e) => (json!({"ok": false, "error": e.to_string()}), false),
         },
         Request::HashState => {
             let records: Vec<_> = match store.iter() {
@@ -324,9 +321,7 @@ fn dispatch<S: Store>(
         Request::DumpRecords => match store.iter() {
             Ok(it) => {
                 let records: Vec<Value> = it
-                    .map(|(entity, id, value)| {
-                        json!({"entity": entity, "id": id, "value": value})
-                    })
+                    .map(|(entity, id, value)| json!({"entity": entity, "id": id, "value": value}))
                     .collect();
                 (json!({"ok": true, "records": records}), false)
             }
@@ -349,4 +344,3 @@ fn hex_encode(bytes: &[u8]) -> String {
     }
     out
 }
-
diff --git a/crates/modules/nakui/core/src/store.rs b/crates/modules/nakui/core/src/store.rs
index 592182f..01d7f4b 100644
--- a/crates/modules/nakui/core/src/store.rs
+++ b/crates/modules/nakui/core/src/store.rs
@@ -128,10 +128,7 @@ pub fn hash_value(hasher: &mut Sha256, v: &Value) {
                 // else (fractions, NaN, infinities) hashes as the raw
                 // f64 bit pattern — that's still deterministic, just
                 // not normalized.
-                if f.is_finite()
-                    && f.fract() == 0.0
-                    && f >= I128_MIN_AS_F64
-                    && f <= I128_MAX_AS_F64
+                if f.is_finite() && f.fract() == 0.0 && f >= I128_MIN_AS_F64 && f <= I128_MAX_AS_F64
                 {
                     hash_int(hasher, f as i128);
                 } else {
@@ -247,22 +244,12 @@ impl Store for MemoryStore {
                     }
                 }
                 FieldOp::Create { entity, id, .. } => {
-                    if self
-                        .records
-                        .get(entity)
-                        .and_then(|m| m.get(id))
-                        .is_some()
-                    {
+                    if self.records.get(entity).and_then(|m| m.get(id)).is_some() {
                         return Err(StoreError::Conflict(entity.clone(), *id));
                     }
                 }
                 FieldOp::Delete { entity, id } => {
-                    if self
-                        .records
-                        .get(entity)
-                        .and_then(|m| m.get(id))
-                        .is_none()
-                    {
+                    if self.records.get(entity).and_then(|m| m.get(id)).is_none() {
                         return Err(StoreError::NotFound(entity.clone(), *id));
                     }
                 }
@@ -343,7 +330,10 @@ impl Store for MemoryStore {
                     .map(move |(id, v)| (entity.clone(), *id, v.clone()))
             })
             .collect();
-        out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
+        out.sort_by(|a, b| {
+            a.0.cmp(&b.0)
+                .then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))
+        });
         Ok(Box::new(out.into_iter()))
     }
 }
@@ -433,7 +423,11 @@ mod tests {
         let after = store.load("Customer", id).unwrap();
         let map = after.as_object().unwrap();
         assert!(!map.contains_key("notes"), "notes debería estar borrado");
-        assert_eq!(map.get("name"), Some(&json!("Acme")), "otros fields intactos");
+        assert_eq!(
+            map.get("name"),
+            Some(&json!("Acme")),
+            "otros fields intactos"
+        );
     }
 
     #[test]
@@ -455,10 +449,7 @@ mod tests {
         // No debería errar: clear de un field ausente es benigno.
         store.apply(&[op]).unwrap();
         let after = store.load("Customer", id).unwrap();
-        assert_eq!(
-            after.as_object().unwrap().get("name"),
-            Some(&json!("Acme"))
-        );
+        assert_eq!(after.as_object().unwrap().get("name"), Some(&json!("Acme")));
     }
 
     #[test]
@@ -680,9 +671,8 @@ mod tests {
         // The empty hash is the SHA-256 of an empty input — fix the
         // expected bytes so an accidental framing change in `hash_state`
         // can't silently sail through.
-        let expected = hex_decode(
-            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
-        );
+        let expected =
+            hex_decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
         assert_eq!(s1.hash_state().unwrap().to_vec(), expected);
     }
 
diff --git a/crates/modules/nakui/core/src/surreal_store.rs b/crates/modules/nakui/core/src/surreal_store.rs
index 6805bff..f2fbd8c 100644
--- a/crates/modules/nakui/core/src/surreal_store.rs
+++ b/crates/modules/nakui/core/src/surreal_store.rs
@@ -18,10 +18,10 @@
 //! a stable shape.
 
 use serde_json::Value;
-use surrealdb::Surreal;
-use surrealdb::engine::local::{Db, Mem};
 #[cfg(feature = "persistent")]
 use surrealdb::engine::local::SurrealKv;
+use surrealdb::engine::local::{Db, Mem};
+use surrealdb::Surreal;
 use thiserror::Error;
 use tokio::runtime::Runtime;
 use uuid::Uuid;
@@ -84,9 +84,7 @@ impl SurrealStore {
     /// canonical use is `let store = SurrealStore::new_persistent(path)?`
     /// at process startup, with the path stable across runs.
     #[cfg(feature = "persistent")]
-    pub fn new_persistent(
-        path: impl AsRef<std::path::Path>,
-    ) -> Result<Self, SurrealStoreError> {
+    pub fn new_persistent(path: impl AsRef<std::path::Path>) -> Result<Self, SurrealStoreError> {
         let runtime = tokio::runtime::Builder::new_current_thread()
             .enable_all()
             .build()?;
@@ -175,10 +173,7 @@ impl Store for SurrealStore {
                         // sobre un field ausente no falla).
                         let exists = self.exists(&path.entity, path.id).await?;
                         if !exists {
-                            return Err(StoreError::NotFound(
-                                path.entity.clone(),
-                                path.id,
-                            ));
+                            return Err(StoreError::NotFound(path.entity.clone(), path.id));
                         }
                         // We don't model NotAnObject for SurrealStore: every
                         // record stored via this trait is map-shaped by
@@ -278,9 +273,11 @@ impl Store for SurrealStore {
                     out.push((table.clone(), id, Value::Object(map)));
                 }
             }
-            out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
-            Ok(Box::new(out.into_iter())
-                as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
+            out.sort_by(|a, b| {
+                a.0.cmp(&b.0)
+                    .then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))
+            });
+            Ok(Box::new(out.into_iter()) as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
         })
     }
 
diff --git a/crates/modules/nakui/core/tests/crm.rs b/crates/modules/nakui/core/tests/crm.rs
new file mode 100644
index 0000000..9c9fa7d
--- /dev/null
+++ b/crates/modules/nakui/core/tests/crm.rs
@@ -0,0 +1,240 @@
+//! Tests de integración del módulo `crm`. Mismo kernel que
+//! inventory/sales/treasury, apuntado a `modules/crm`: clientes,
+//! oportunidades que recorren un pipeline de ventas, e interacciones.
+
+use std::path::{Path, PathBuf};
+
+use nakui_core::executor::{ExecError, Executor};
+use nakui_core::store::{MemoryStore, Store};
+use serde_json::{json, Value};
+use uuid::Uuid;
+
+fn crm_module() -> PathBuf {
+    Path::new(env!("CARGO_MANIFEST_DIR"))
+        .parent()
+        .expect("dir del módulo nakui sobre core/")
+        .join("modules/crm")
+}
+
+fn seed_cliente(store: &mut MemoryStore, id: Uuid, nombre: &str) {
+    store.seed(
+        "Cliente",
+        id,
+        json!({
+            "id": id.to_string(),
+            "nombre": nombre,
+            "email": "contacto@example.com",
+            "empresa": nombre,
+        }),
+    );
+}
+
+/// Abre una oportunidad y devuelve su id. Camino feliz (panica si falla).
+fn abrir_opp(exec: &Executor, store: &mut MemoryStore, cliente: Uuid) -> Uuid {
+    let opp = Uuid::new_v4();
+    exec.run(
+        store,
+        "abrir_oportunidad",
+        &[("cliente", cliente)],
+        json!({
+            "oportunidad_id": opp.to_string(),
+            "titulo": "Licencia anual",
+            "monto": 12_000_i64,
+            "currency": "USD",
+            "timestamp": "2026-05-21T10:00:00Z",
+        }),
+    )
+    .expect("abrir_oportunidad debe pasar");
+    opp
+}
+
+fn etapa(store: &MemoryStore, opp: Uuid) -> String {
+    store
+        .load("Oportunidad", opp)
+        .and_then(|v| v.get("etapa").and_then(Value::as_str).map(String::from))
+        .expect("oportunidad con etapa")
+}
+
+/// Corre `mover_oportunidad`; devuelve el conteo de ops en éxito.
+// `ExecError` es un enum grande — el resto del crate convive con este
+// lint; lo suprimimos local en vez de boxear sólo este helper.
+#[allow(clippy::result_large_err)]
+fn mover(
+    exec: &Executor,
+    store: &mut MemoryStore,
+    opp: Uuid,
+    destino: &str,
+) -> Result<usize, ExecError> {
+    exec.run(
+        store,
+        "mover_oportunidad",
+        &[("oportunidad", opp)],
+        json!({ "etapa": destino, "timestamp": "2026-05-21T11:00:00Z" }),
+    )
+    .map(|ops| ops.len())
+}
+
+#[test]
+fn abrir_crea_oportunidad_en_prospecto() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+
+    let opp = abrir_opp(&exec, &mut store, cliente);
+
+    assert_eq!(etapa(&store, opp), "prospecto", "nace en prospecto");
+    let o = store.load("Oportunidad", opp).expect("oportunidad existe");
+    let cid = cliente.to_string();
+    assert_eq!(
+        o.get("cliente_id").and_then(Value::as_str),
+        Some(cid.as_str())
+    );
+    assert_eq!(o.get("monto").and_then(Value::as_i64), Some(12_000));
+}
+
+#[test]
+fn pipeline_avanza_hasta_ganada() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+    let opp = abrir_opp(&exec, &mut store, cliente);
+
+    for destino in ["calificado", "propuesta", "negociacion", "ganada"] {
+        mover(&exec, &mut store, opp, destino)
+            .unwrap_or_else(|e| panic!("mover a {destino} debe pasar: {e:?}"));
+        assert_eq!(etapa(&store, opp), destino);
+    }
+}
+
+#[test]
+fn no_se_retrocede_en_el_pipeline() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+    let opp = abrir_opp(&exec, &mut store, cliente);
+
+    mover(&exec, &mut store, opp, "propuesta").expect("avanzar debe pasar");
+
+    // prospecto está antes de propuesta → retroceso, rechazado por el script.
+    let result = mover(&exec, &mut store, opp, "prospecto");
+    match result {
+        Err(ExecError::Rhai(_)) => {}
+        other => panic!("esperaba Rhai (throw por retroceso), obtuve {other:?}"),
+    }
+    assert_eq!(etapa(&store, opp), "propuesta", "la etapa no cambió");
+}
+
+#[test]
+fn oportunidad_cerrada_no_se_mueve() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+    let opp = abrir_opp(&exec, &mut store, cliente);
+
+    // Cerrar es legal desde cualquier etapa abierta.
+    mover(&exec, &mut store, opp, "ganada").expect("cerrar debe pasar");
+
+    // Una oportunidad ganada ya no se mueve.
+    let result = mover(&exec, &mut store, opp, "negociacion");
+    match result {
+        Err(ExecError::Rhai(_)) => {}
+        other => panic!("esperaba Rhai (throw por cerrada), obtuve {other:?}"),
+    }
+    assert_eq!(etapa(&store, opp), "ganada");
+}
+
+#[test]
+fn etapa_destino_desconocida_es_rechazada() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+    let opp = abrir_opp(&exec, &mut store, cliente);
+
+    let result = mover(&exec, &mut store, opp, "facturada");
+    assert!(matches!(result, Err(ExecError::Rhai(_))));
+    assert_eq!(etapa(&store, opp), "prospecto");
+}
+
+#[test]
+fn monto_negativo_es_rechazado() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+
+    let opp = Uuid::new_v4();
+    let result = exec.run(
+        &mut store,
+        "abrir_oportunidad",
+        &[("cliente", cliente)],
+        json!({
+            "oportunidad_id": opp.to_string(),
+            "titulo": "Trato inválido",
+            "monto": -500_i64,
+            "currency": "USD",
+            "timestamp": "2026-05-21T10:00:00Z",
+        }),
+    );
+    assert!(matches!(result, Err(ExecError::Rhai(_))));
+    assert!(store.load("Oportunidad", opp).is_none(), "no se creó nada");
+}
+
+#[test]
+fn registrar_interaccion_crea_registro() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+
+    let int_id = Uuid::new_v4();
+    exec.run(
+        &mut store,
+        "registrar_interaccion",
+        &[("cliente", cliente)],
+        json!({
+            "interaccion_id": int_id.to_string(),
+            "canal": "llamada",
+            "nota": "Primer contacto, interés alto",
+            "timestamp": "2026-05-21T09:00:00Z",
+        }),
+    )
+    .expect("registrar_interaccion debe pasar");
+
+    let i = store
+        .load("Interaccion", int_id)
+        .expect("interacción existe");
+    assert_eq!(i.get("canal").and_then(Value::as_str), Some("llamada"));
+    let cid = cliente.to_string();
+    assert_eq!(
+        i.get("cliente_id").and_then(Value::as_str),
+        Some(cid.as_str())
+    );
+}
+
+#[test]
+fn canal_invalido_es_rechazado() {
+    let exec = Executor::load_module(crm_module()).expect("load module");
+    let mut store = MemoryStore::new();
+    let cliente = Uuid::new_v4();
+    seed_cliente(&mut store, cliente, "Acme Corp");
+
+    let int_id = Uuid::new_v4();
+    let result = exec.run(
+        &mut store,
+        "registrar_interaccion",
+        &[("cliente", cliente)],
+        json!({
+            "interaccion_id": int_id.to_string(),
+            "canal": "paloma-mensajera",
+            "nota": "canal inexistente",
+            "timestamp": "2026-05-21T09:00:00Z",
+        }),
+    );
+    assert!(matches!(result, Err(ExecError::Rhai(_))));
+    assert!(store.load("Interaccion", int_id).is_none());
+}
diff --git a/crates/modules/nakui/core/tests/drift.rs b/crates/modules/nakui/core/tests/drift.rs
index 5c0c10a..5f59d32 100644
--- a/crates/modules/nakui/core/tests/drift.rs
+++ b/crates/modules/nakui/core/tests/drift.rs
@@ -11,8 +11,8 @@ use std::os::unix::net::UnixStream;
 use std::path::{Path, PathBuf};
 use std::thread;
 
-use nakui_core::drift::{DriftDiff, check_against_socket};
-use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
+use nakui_core::drift::{check_against_socket, DriftDiff};
+use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
 use nakui_core::executor::Executor;
 use nakui_core::run::run_server;
 use nakui_core::store::MemoryStore;
diff --git a/crates/modules/nakui/core/tests/event_log.rs b/crates/modules/nakui/core/tests/event_log.rs
index d943c74..39b7602 100644
--- a/crates/modules/nakui/core/tests/event_log.rs
+++ b/crates/modules/nakui/core/tests/event_log.rs
@@ -5,13 +5,12 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::delta::FieldOp;
 use nakui_core::event_log::{
-    EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log,
-    execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log,
-    verify_log,
+    execute_and_log, execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into,
+    seed_and_log, verify_log, EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store, StoreError};
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -247,11 +246,21 @@ fn post_log_store_failure_leaves_log_canonical() {
 
     // Live store is stale: apply was rejected, so saldos are unchanged.
     assert_eq!(
-        store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(),
+        store
+            .load("Caja", a)
+            .unwrap()
+            .get("saldo")
+            .unwrap()
+            .as_i64(),
         Some(200_000)
     );
     assert_eq!(
-        store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(),
+        store
+            .load("Caja", b)
+            .unwrap()
+            .get("saldo")
+            .unwrap()
+            .as_i64(),
         Some(50_000)
     );
 
@@ -464,7 +473,10 @@ fn snapshot_then_compact_then_replay_equals_pre_compaction() {
     let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
     let mut replayed = MemoryStore::new();
     replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
-    assert_eq!(live, replayed, "snapshot + post-compact log must equal live");
+    assert_eq!(
+        live, replayed,
+        "snapshot + post-compact log must equal live"
+    );
 
     let _ = std::fs::remove_file(&log_path);
     let _ = std::fs::remove_file(&snap_path);
@@ -520,8 +532,14 @@ fn reconcile_rebuilds_drifted_store_from_log() {
     assert_ne!(live, canonical, "drift was set up to differ from log");
 
     reconcile(&mut live, &log).expect("reconcile");
-    assert_eq!(live, canonical, "reconcile must restore log-canonical state");
-    assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped");
+    assert_eq!(
+        live, canonical,
+        "reconcile must restore log-canonical state"
+    );
+    assert!(
+        live.load("Caja", ghost).is_none(),
+        "poison record must be wiped"
+    );
 
     let _ = std::fs::remove_file(&log_path);
 }
diff --git a/crates/modules/nakui/core/tests/graph.rs b/crates/modules/nakui/core/tests/graph.rs
index 079cf05..866cdd1 100644
--- a/crates/modules/nakui/core/tests/graph.rs
+++ b/crates/modules/nakui/core/tests/graph.rs
@@ -5,9 +5,7 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::executor::Executor;
 use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
-use nakui_core::manifest::{
-    ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
-};
+use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
 
 fn workspace_root() -> PathBuf {
     Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -114,14 +112,28 @@ fn treasury_data_flow_indexes_match_manifest() {
     let g = &exec.graph;
 
     // Both register_cash_move and transfer_between_cajas write Caja.saldo.
-    let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
+    let mut writers: Vec<&str> = g
+        .writers_of("Caja.saldo")
+        .iter()
+        .map(|s| s.as_str())
+        .collect();
     writers.sort();
-    assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
+    assert_eq!(
+        writers,
+        vec!["register_cash_move", "transfer_between_cajas"]
+    );
 
     // Both read Caja.saldo too.
-    let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
+    let mut readers: Vec<&str> = g
+        .readers_of("Caja.saldo")
+        .iter()
+        .map(|s| s.as_str())
+        .collect();
     readers.sort();
-    assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
+    assert_eq!(
+        readers,
+        vec!["register_cash_move", "transfer_between_cajas"]
+    );
 
     // Movimiento is written only by register_cash_move.
     assert_eq!(
@@ -246,8 +258,11 @@ fn executor_load_module_rejects_cyclic_manifest() {
         Err(e) => e,
     };
     let msg = err.to_string();
-    assert!(msg.contains("graph") || msg.contains("cycle"),
-        "expected graph diagnostic, got `{}`", msg);
+    assert!(
+        msg.contains("graph") || msg.contains("cycle"),
+        "expected graph diagnostic, got `{}`",
+        msg
+    );
 
     let _ = std::fs::remove_dir_all(&tmp);
 }
diff --git a/crates/modules/nakui/core/tests/inventory.rs b/crates/modules/nakui/core/tests/inventory.rs
index f5a61f2..1e4b52d 100644
--- a/crates/modules/nakui/core/tests/inventory.rs
+++ b/crates/modules/nakui/core/tests/inventory.rs
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::executor::{ExecError, Executor};
 use nakui_core::store::{MemoryStore, Store};
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -104,7 +104,10 @@ fn transfer_across_different_skus_is_rejected_by_conservation() {
 
     match result {
         Err(ExecError::Rhai(_)) => {}
-        other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other),
+        other => panic!(
+            "expected Rhai (script throw on sku mismatch), got {:?}",
+            other
+        ),
     }
     assert_eq!(cantidad(&store, a), 500);
     assert_eq!(cantidad(&store, c), 200);
diff --git a/crates/modules/nakui/core/tests/kernel_guards.rs b/crates/modules/nakui/core/tests/kernel_guards.rs
index e2930d5..b443fe8 100644
--- a/crates/modules/nakui/core/tests/kernel_guards.rs
+++ b/crates/modules/nakui/core/tests/kernel_guards.rs
@@ -13,12 +13,10 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::executor::{ExecError, Executor};
 use nakui_core::graph::ManifestGraph;
-use nakui_core::manifest::{
-    ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
-};
+use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
 use nakui_core::rhai_executor::RhaiExecutor;
 use nakui_core::store::{MemoryStore, Store};
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -207,7 +205,12 @@ fn capability_rejects_entity_mismatch_on_tracked_id() {
     let caja_id = Uuid::new_v4();
     seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
 
-    let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({}));
+    let result = exec.run(
+        &mut store,
+        "evil_entity_mismatch",
+        &[("caja", caja_id)],
+        json!({}),
+    );
 
     match result {
         Err(ExecError::CapabilityViolation { token, .. }) => {
diff --git a/crates/modules/nakui/core/tests/manifest_validation.rs b/crates/modules/nakui/core/tests/manifest_validation.rs
index 76769d3..2577bc3 100644
--- a/crates/modules/nakui/core/tests/manifest_validation.rs
+++ b/crates/modules/nakui/core/tests/manifest_validation.rs
@@ -173,7 +173,9 @@ fn rejects_missing_script() {
     let mut m = baseline_manifest();
     m.morphisms[0].script = "morphisms/ghost.rhai".into();
     match m.validate(&treasury_dir()) {
-        Err(ValidationError::ScriptMissing { morphism, script, .. }) => {
+        Err(ValidationError::ScriptMissing {
+            morphism, script, ..
+        }) => {
             assert_eq!(morphism, "test_op");
             assert_eq!(script, "morphisms/ghost.rhai");
         }
@@ -200,16 +202,8 @@ fn rejects_duplicate_schema_across_files() {
     let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
     fs::create_dir_all(&tmp).unwrap();
     fs::create_dir_all(tmp.join("morphisms")).unwrap();
-    fs::write(
-        tmp.join("a.ncl"),
-        "{\n  Caja = { saldo | Number },\n}\n",
-    )
-    .unwrap();
-    fs::write(
-        tmp.join("b.ncl"),
-        "{\n  Caja = { monto | Number },\n}\n",
-    )
-    .unwrap();
+    fs::write(tmp.join("a.ncl"), "{\n  Caja = { saldo | Number },\n}\n").unwrap();
+    fs::write(tmp.join("b.ncl"), "{\n  Caja = { monto | Number },\n}\n").unwrap();
     fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
 
     let m = Manifest {
diff --git a/crates/modules/nakui/core/tests/run.rs b/crates/modules/nakui/core/tests/run.rs
index 94f7e8b..c03d249 100644
--- a/crates/modules/nakui/core/tests/run.rs
+++ b/crates/modules/nakui/core/tests/run.rs
@@ -14,11 +14,11 @@ use std::path::{Path, PathBuf};
 use std::thread;
 use std::time::Duration;
 
-use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
+use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
 use nakui_core::executor::Executor;
 use nakui_core::run::run_server;
 use nakui_core::store::MemoryStore;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -118,9 +118,7 @@ fn run_server_full_protocol_round_trip() {
         if resp["protocol"] != json!(1) {
             return Err(format!("protocol mismatch: {}", resp));
         }
-        let morphisms = resp["morphisms"]
-            .as_array()
-            .ok_or("morphisms not array")?;
+        let morphisms = resp["morphisms"].as_array().ok_or("morphisms not array")?;
         if !morphisms.iter().any(|m| m["name"] == "register_cash_move") {
             return Err("register_cash_move missing from describe".into());
         }
@@ -183,9 +181,13 @@ fn run_server_full_protocol_round_trip() {
         }
 
         // Bad JSON — connection survives, server keeps serving.
-        conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?;
+        conn.writer
+            .write_all(b"not json\n")
+            .map_err(|e| e.to_string())?;
         let mut line = String::new();
-        conn.reader.read_line(&mut line).map_err(|e| e.to_string())?;
+        conn.reader
+            .read_line(&mut line)
+            .map_err(|e| e.to_string())?;
         let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?;
         if parsed["ok"] != json!(false) {
             return Err(format!("bad request didn't get error: {}", parsed));
@@ -207,7 +209,10 @@ fn run_server_full_protocol_round_trip() {
     });
 
     run_server(executor, log, store, None, &socket_path).expect("server clean exit");
-    client.join().expect("client thread joined").expect("client assertions");
+    client
+        .join()
+        .expect("client thread joined")
+        .expect("client assertions");
 
     assert!(
         !socket_path.exists(),
diff --git a/crates/modules/nakui/core/tests/run_persistent.rs b/crates/modules/nakui/core/tests/run_persistent.rs
index 63bcfa5..2a74cb5 100644
--- a/crates/modules/nakui/core/tests/run_persistent.rs
+++ b/crates/modules/nakui/core/tests/run_persistent.rs
@@ -29,12 +29,12 @@ use std::path::{Path, PathBuf};
 use std::thread;
 use std::time::Duration;
 
-use nakui_core::event_log::{EventLog, seed_and_log};
+use nakui_core::event_log::{seed_and_log, EventLog};
 use nakui_core::executor::Executor;
 use nakui_core::run::run_server;
 use nakui_core::store::Store;
 use nakui_core::surreal_store::SurrealStore;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -232,8 +232,7 @@ fn run_server_skips_replay_when_persistent_store_is_in_sync() {
     // Out-of-band mutation: open the persistent store directly and
     // change the saldo. Marker stays at the same seq.
     {
-        let mut store =
-            SurrealStore::new_persistent(&store_path).expect("reopen for poison");
+        let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for poison");
         store.seed(
             "Caja",
             caja,
diff --git a/crates/modules/nakui/core/tests/sales.rs b/crates/modules/nakui/core/tests/sales.rs
index 14b402d..e20ee4d 100644
--- a/crates/modules/nakui/core/tests/sales.rs
+++ b/crates/modules/nakui/core/tests/sales.rs
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::executor::{ExecError, Executor};
 use nakui_core::store::{MemoryStore, Store};
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -95,10 +95,7 @@ fn sale_decreases_stock_and_increases_caja() {
         .expect("venta must be persisted");
     assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000));
     assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100));
-    assert_eq!(
-        venta.get("currency").and_then(Value::as_str),
-        Some("USD")
-    );
+    assert_eq!(venta.get("currency").and_then(Value::as_str), Some("USD"));
 }
 
 #[test]
diff --git a/crates/modules/nakui/core/tests/schema_versioning.rs b/crates/modules/nakui/core/tests/schema_versioning.rs
index abf15a1..7b485c4 100644
--- a/crates/modules/nakui/core/tests/schema_versioning.rs
+++ b/crates/modules/nakui/core/tests/schema_versioning.rs
@@ -10,11 +10,11 @@
 use std::path::{Path, PathBuf};
 
 use nakui_core::event_log::{
-    EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log,
+    execute_and_log, replay, seed_and_log, verify_log, EventLog, LogEntry, VerifyError,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::MemoryStore;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -66,12 +66,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
     Ok(())
 }
 
-fn deposit_5k(
-    exec: &Executor,
-    store: &mut MemoryStore,
-    log: &mut EventLog,
-    caja: Uuid,
-) {
+fn deposit_5k(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid) {
     execute_and_log(
         exec,
         store,
@@ -122,7 +117,10 @@ fn executor_exposes_per_morphism_schema_hash() {
     // Re-loading the same module yields the same hashes — the contract
     // depends only on the bytes on disk, not load-time state.
     let exec2 = Executor::load_module(treasury_module()).expect("reload");
-    assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move"));
+    assert_eq!(
+        exec.schema_hash("register_cash_move"),
+        exec2.schema_hash("register_cash_move")
+    );
 }
 
 #[test]
@@ -202,7 +200,10 @@ fn verify_log_rejects_log_after_morphism_script_changes() {
     // Reload — the hash for register_cash_move must change.
     let exec2 = Executor::load_module(&temp.path).expect("reload v2");
     let new_hash = exec2.schema_hash("register_cash_move").unwrap();
-    assert_ne!(original_hash, new_hash, "real source edit must move the hash");
+    assert_ne!(
+        original_hash, new_hash,
+        "real source edit must move the hash"
+    );
 
     // verify_log must surface SchemaMismatch, not OpsMismatch — the
     // schema check runs first because "rules changed" is more
@@ -361,7 +362,10 @@ fn verify_log_rejects_seed_after_schema_changes() {
 
     let exec2 = Executor::load_module(&temp.path).expect("reload v2");
     let new_hash = exec2.schema_bundle_hash;
-    assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash");
+    assert_ne!(
+        original_hash, new_hash,
+        "schema.ncl byte change must move the bundle hash"
+    );
 
     let log = EventLog::open(&log_path).unwrap();
     match verify_log(&log, &exec2) {
@@ -433,7 +437,11 @@ fn morphism_script_change_does_not_flag_unrelated_seeds() {
     // Modify a Rhai script. Bundle stays the same.
     let script_path = temp.path.join("morphisms/register_cash_move.rhai");
     let original = std::fs::read_to_string(&script_path).expect("read");
-    std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap();
+    std::fs::write(
+        &script_path,
+        format!("{}\n// rhai-only mutation\n", original),
+    )
+    .unwrap();
 
     let exec2 = Executor::load_module(&temp.path).expect("reload");
     let log = EventLog::open(&log_path).unwrap();
diff --git a/crates/modules/nakui/core/tests/snapshot_chain.rs b/crates/modules/nakui/core/tests/snapshot_chain.rs
index afecc30..a7677cf 100644
--- a/crates/modules/nakui/core/tests/snapshot_chain.rs
+++ b/crates/modules/nakui/core/tests/snapshot_chain.rs
@@ -9,12 +9,12 @@ use std::thread;
 use std::time::Duration;
 
 use nakui_core::event_log::{
-    EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log,
+    execute_and_log, replay, seed_and_log, EventLog, Snapshot, SnapshotMismatchError,
 };
 use nakui_core::executor::Executor;
 use nakui_core::run::run_server;
 use nakui_core::store::{MemoryStore, Store};
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -166,7 +166,10 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
     let socket_for_client = socket_path.clone();
     let client = thread::spawn(move || -> Result<(), String> {
         let mut conn = connect_with_retry(&socket_for_client);
-        let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
+        let resp = exchange(
+            &mut conn,
+            json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
+        );
         if resp["value"]["saldo"].as_i64() != Some(112_500) {
             return Err(format!(
                 "expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}",
@@ -191,9 +194,15 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
             }),
         );
         if resp["ok"] != json!(true) {
-            return Err(format!("execute on snapshot-booted server failed: {}", resp));
+            return Err(format!(
+                "execute on snapshot-booted server failed: {}",
+                resp
+            ));
         }
-        let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
+        let resp = exchange(
+            &mut conn,
+            json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
+        );
         if resp["value"]["saldo"].as_i64() != Some(113_500) {
             return Err(format!("post-execute saldo wrong: {}", resp));
         }
@@ -234,10 +243,7 @@ fn run_server_refuses_snapshot_with_wrong_schema_hash() {
     let store = MemoryStore::new();
     let result = run_server(exec, log, store, Some(bad_snap), &socket_path);
     assert!(
-        matches!(
-            result,
-            Err(nakui_core::run::RunError::SnapshotMismatch(_))
-        ),
+        matches!(result, Err(nakui_core::run::RunError::SnapshotMismatch(_))),
         "expected SnapshotMismatch, got {:?}",
         result
     );
@@ -352,7 +358,8 @@ fn snapshot_write_recovers_from_stale_tempfile() {
 
     let exec = Executor::load_module(treasury_module()).expect("load");
     let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
-    snap.write(&snap_path).expect("write despite stale tempfile");
+    snap.write(&snap_path)
+        .expect("write despite stale tempfile");
 
     // Tempfile should be renamed (not orphaned), so it's gone.
     assert!(
diff --git a/crates/modules/nakui/core/tests/state_hash.rs b/crates/modules/nakui/core/tests/state_hash.rs
index 19a3566..b7964ed 100644
--- a/crates/modules/nakui/core/tests/state_hash.rs
+++ b/crates/modules/nakui/core/tests/state_hash.rs
@@ -6,7 +6,7 @@
 
 use std::path::{Path, PathBuf};
 
-use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log};
+use nakui_core::event_log::{execute_and_log, replay, seed_and_log, EventLog};
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
 use serde_json::json;
@@ -27,13 +27,7 @@ fn fresh_log_path() -> PathBuf {
     std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4()))
 }
 
-fn seed_two_cajas(
-    exec: &Executor,
-    store: &mut MemoryStore,
-    log: &mut EventLog,
-    a: Uuid,
-    b: Uuid,
-) {
+fn seed_two_cajas(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, a: Uuid, b: Uuid) {
     seed_and_log(
         exec,
         store,
diff --git a/crates/modules/nakui/core/tests/surreal_persist.rs b/crates/modules/nakui/core/tests/surreal_persist.rs
index 3963b1a..e49592c 100644
--- a/crates/modules/nakui/core/tests/surreal_persist.rs
+++ b/crates/modules/nakui/core/tests/surreal_persist.rs
@@ -10,7 +10,7 @@ use std::path::PathBuf;
 
 use nakui_core::store::Store;
 use nakui_core::surreal_store::SurrealStore;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn fresh_db_path() -> PathBuf {
@@ -39,9 +39,7 @@ fn data_survives_close_and_reopen() {
 
     {
         let store = SurrealStore::new_persistent(&path).expect("reopen persistent");
-        let loaded = store
-            .load("Caja", id)
-            .expect("record must survive reopen");
+        let loaded = store.load("Caja", id).expect("record must survive reopen");
         assert_eq!(
             loaded.get("saldo").and_then(Value::as_i64),
             Some(12_345),
diff --git a/crates/modules/nakui/core/tests/surreal_store.rs b/crates/modules/nakui/core/tests/surreal_store.rs
index da12b88..2d49b6a 100644
--- a/crates/modules/nakui/core/tests/surreal_store.rs
+++ b/crates/modules/nakui/core/tests/surreal_store.rs
@@ -8,12 +8,12 @@ use std::path::{Path, PathBuf};
 
 use nakui_core::delta::{FieldOp, FieldPath};
 use nakui_core::event_log::{
-    EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log,
+    execute_and_log, reconcile, replay_into, seed_and_log, verify_log, EventLog,
 };
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store, StoreError};
 use nakui_core::surreal_store::SurrealStore;
-use serde_json::{Value, json};
+use serde_json::{json, Value};
 use uuid::Uuid;
 
 fn workspace_root() -> PathBuf {
@@ -240,8 +240,24 @@ fn verify_log_against_surreal_passes() {
 
     let a = Uuid::new_v4();
     let b = Uuid::new_v4();
-    seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
-    seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
+    seed_and_log(
+        &exec,
+        &mut live,
+        &mut log,
+        "Caja",
+        a,
+        caja_data(a, 200_000, "USD"),
+    )
+    .unwrap();
+    seed_and_log(
+        &exec,
+        &mut live,
+        &mut log,
+        "Caja",
+        b,
+        caja_data(b, 50_000, "USD"),
+    )
+    .unwrap();
     execute_and_log(
         &exec,
         &mut live,
@@ -432,8 +448,24 @@ fn iter_and_hash_state_round_trip_against_surreal() {
     let mut live = SurrealStore::new_in_memory().expect("live");
     let a = Uuid::new_v4();
     let b = Uuid::new_v4();
-    seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
-    seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
+    seed_and_log(
+        &exec,
+        &mut live,
+        &mut log,
+        "Caja",
+        a,
+        caja_data(a, 200_000, "USD"),
+    )
+    .unwrap();
+    seed_and_log(
+        &exec,
+        &mut live,
+        &mut log,
+        "Caja",
+        b,
+        caja_data(b, 50_000, "USD"),
+    )
+    .unwrap();
     execute_and_log(
         &exec,
         &mut live,
@@ -453,12 +485,17 @@ fn iter_and_hash_state_round_trip_against_surreal() {
     // iter must enumerate every record.
     let recs: Vec<_> = live.iter().expect("iter").collect();
     let by_entity: std::collections::HashMap<&str, usize> =
-        recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
-            *m.entry(e.as_str()).or_insert(0) += 1;
-            m
-        });
+        recs.iter()
+            .fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
+                *m.entry(e.as_str()).or_insert(0) += 1;
+                m
+            });
     assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas");
-    assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento");
+    assert_eq!(
+        by_entity.get("Movimiento").copied(),
+        Some(1),
+        "one Movimiento"
+    );
 
     // canonical order: entities sorted, ids byte-sorted within entity.
     let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect();
@@ -496,7 +533,15 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
     let mut store = SurrealStore::new_in_memory().expect("surreal");
 
     let a = Uuid::new_v4();
-    seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap();
+    seed_and_log(
+        &exec,
+        &mut store,
+        &mut log,
+        "Caja",
+        a,
+        caja_data(a, 100_000, "USD"),
+    )
+    .unwrap();
     execute_and_log(
         &exec,
         &mut store,
@@ -518,7 +563,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
     store.seed("Caja", ghost, caja_data(ghost, 0, "USD"));
     store.seed("Caja", a, caja_data(a, 999_999, "USD"));
     assert_eq!(
-        store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
+        store
+            .load("Caja", a)
+            .and_then(|v| v.get("saldo").and_then(Value::as_i64)),
         Some(999_999),
         "drift was applied"
     );
@@ -528,7 +575,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
     // After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit).
     assert!(store.load("Caja", ghost).is_none(), "poison record wiped");
     assert_eq!(
-        store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
+        store
+            .load("Caja", a)
+            .and_then(|v| v.get("saldo").and_then(Value::as_i64)),
         Some(105_000),
         "reconcile must restore log-canonical saldo"
     );
diff --git a/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai b/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai
new file mode 100644
index 0000000..c6f5ce2
--- /dev/null
+++ b/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai
@@ -0,0 +1,39 @@
+// abrir_oportunidad
+// Abre una oportunidad de venta para un cliente existente. La etapa
+// inicial la fija el morfismo ("prospecto"), no el caller — una
+// oportunidad siempre nace al principio del pipeline.
+//
+// ids.cliente: UUID del Cliente (la oportunidad guarda la FK).
+// params: { oportunidad_id, titulo, monto:i64, currency:str, timestamp:str }
+
+let opp_id = input.params.oportunidad_id;
+if type_of(opp_id) == "()" {
+    throw "params.oportunidad_id es obligatorio (idempotencia)"
+}
+
+let titulo = input.params.titulo;
+if type_of(titulo) == "()" {
+    throw "params.titulo es obligatorio"
+}
+
+let monto = input.params.monto;
+if monto <= 0 {
+    throw "monto debe ser positivo"
+}
+
+[
+    #{
+        op: "create",
+        entity: "Oportunidad",
+        id: opp_id,
+        data: #{
+            id:         opp_id,
+            cliente_id: input.ids.cliente,
+            titulo:     titulo,
+            monto:      monto,
+            currency:   input.params.currency,
+            etapa:      "prospecto",
+            timestamp:  input.params.timestamp,
+        },
+    },
+]
diff --git a/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai b/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai
new file mode 100644
index 0000000..9d3d7a4
--- /dev/null
+++ b/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai
@@ -0,0 +1,55 @@
+// mover_oportunidad
+// Mueve una oportunidad por el pipeline de ventas. La etapa destino la
+// fija el caller; el morfismo valida que la transición sea legal:
+//  - la etapa destino debe ser conocida,
+//  - una oportunidad cerrada (ganada/perdida) ya no se mueve,
+//  - dentro del pipeline abierto sólo se avanza, no se retrocede;
+//    cerrar (ganada/perdida) es legal desde cualquier etapa abierta.
+//
+// states.oportunidad: el registro Oportunidad.
+// params: { etapa:str, timestamp:str }
+
+let etapa_actual  = input.states.oportunidad.etapa;
+let etapa_destino = input.params.etapa;
+
+// Rango de cada etapa en el pipeline (-1 = etapa desconocida).
+let r_actual = -1;
+if etapa_actual == "prospecto"   { r_actual = 0; }
+if etapa_actual == "calificado"  { r_actual = 1; }
+if etapa_actual == "propuesta"   { r_actual = 2; }
+if etapa_actual == "negociacion" { r_actual = 3; }
+if etapa_actual == "ganada"      { r_actual = 4; }
+if etapa_actual == "perdida"     { r_actual = 5; }
+
+let r_destino = -1;
+if etapa_destino == "prospecto"   { r_destino = 0; }
+if etapa_destino == "calificado"  { r_destino = 1; }
+if etapa_destino == "propuesta"   { r_destino = 2; }
+if etapa_destino == "negociacion" { r_destino = 3; }
+if etapa_destino == "ganada"      { r_destino = 4; }
+if etapa_destino == "perdida"     { r_destino = 5; }
+
+if r_destino < 0 {
+    throw "etapa destino desconocida: " + etapa_destino
+}
+if etapa_actual == "ganada" || etapa_actual == "perdida" {
+    throw "la oportunidad ya está cerrada (etapa " + etapa_actual + ")"
+}
+
+// Cerrar es legal desde cualquier etapa abierta; dentro del pipeline
+// abierto sólo se avanza.
+let cerrando = etapa_destino == "ganada" || etapa_destino == "perdida";
+if !cerrando {
+    if r_destino <= r_actual {
+        throw "transición inválida " + etapa_actual + " -> " + etapa_destino
+            + " (no se retrocede)"
+    }
+}
+
+[
+    #{
+        op: "set",
+        path: #{ entity: "Oportunidad", id: input.ids.oportunidad, field: "etapa" },
+        value: etapa_destino,
+    },
+]
diff --git a/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai b/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai
new file mode 100644
index 0000000..4a7e9aa
--- /dev/null
+++ b/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai
@@ -0,0 +1,31 @@
+// registrar_interaccion
+// Registra una interacción (llamada, email o reunión) con un cliente.
+// Crea un registro Interaccion — el historial de contacto del CRM.
+//
+// ids.cliente: UUID del Cliente (la interacción guarda la FK).
+// params: { interaccion_id, canal:str, nota:str, timestamp:str }
+
+let int_id = input.params.interaccion_id;
+if type_of(int_id) == "()" {
+    throw "params.interaccion_id es obligatorio (idempotencia)"
+}
+
+let canal = input.params.canal;
+if canal != "llamada" && canal != "email" && canal != "reunion" {
+    throw "canal desconocido: " + canal + " (esperado: llamada | email | reunion)"
+}
+
+[
+    #{
+        op: "create",
+        entity: "Interaccion",
+        id: int_id,
+        data: #{
+            id:         int_id,
+            cliente_id: input.ids.cliente,
+            canal:      canal,
+            nota:       input.params.nota,
+            timestamp:  input.params.timestamp,
+        },
+    },
+]
diff --git a/crates/modules/nakui/modules/crm/nsmc.json b/crates/modules/nakui/modules/crm/nsmc.json
new file mode 100644
index 0000000..e9698b4
--- /dev/null
+++ b/crates/modules/nakui/modules/crm/nsmc.json
@@ -0,0 +1,35 @@
+{
+  "module": "crm",
+  "morphisms": [
+    {
+      "name": "abrir_oportunidad",
+      "inputs": [
+        { "role": "cliente", "entity": "Cliente" }
+      ],
+      "reads": [],
+      "writes": ["Oportunidad"],
+      "depends_on": [],
+      "script": "morphisms/abrir_oportunidad.rhai"
+    },
+    {
+      "name": "mover_oportunidad",
+      "inputs": [
+        { "role": "oportunidad", "entity": "Oportunidad" }
+      ],
+      "reads": ["oportunidad.etapa"],
+      "writes": ["oportunidad.etapa"],
+      "depends_on": [],
+      "script": "morphisms/mover_oportunidad.rhai"
+    },
+    {
+      "name": "registrar_interaccion",
+      "inputs": [
+        { "role": "cliente", "entity": "Cliente" }
+      ],
+      "reads": [],
+      "writes": ["Interaccion"],
+      "depends_on": [],
+      "script": "morphisms/registrar_interaccion.rhai"
+    }
+  ]
+}
diff --git a/crates/modules/nakui/modules/crm/schema.ncl b/crates/modules/nakui/modules/crm/schema.ncl
new file mode 100644
index 0000000..5ad741b
--- /dev/null
+++ b/crates/modules/nakui/modules/crm/schema.ncl
@@ -0,0 +1,55 @@
+# Schema Nickel para el módulo `crm` (Cliente + Oportunidad + Interaccion).
+#
+# Un CRM: clientes, oportunidades de venta que recorren un pipeline e
+# interacciones registradas. El kernel de Nakui valida cada entity
+# contra el contract de su record — antes y después de cada morfismo.
+
+let non_empty_string = std.contract.from_predicate (fun s =>
+  std.is_string s && std.string.length s > 0
+) in
+
+let positive_int = std.contract.from_predicate (fun n =>
+  std.is_number n && n > 0
+) in
+
+let currency_iso = std.contract.from_predicate (fun s =>
+  std.is_string s && std.string.length s == 3
+) in
+
+# Etapas del pipeline de ventas. `ganada` y `perdida` son terminales.
+let etapa_pipeline = std.contract.from_predicate (fun s =>
+  std.is_string s
+    && std.array.elem s
+      ["prospecto", "calificado", "propuesta", "negociacion", "ganada", "perdida"]
+) in
+
+let canal_interaccion = std.contract.from_predicate (fun s =>
+  std.is_string s && std.array.elem s ["llamada", "email", "reunion"]
+) in
+
+{
+  Cliente = {
+    id | String,
+    nombre | non_empty_string,
+    email | non_empty_string,
+    empresa | String,
+  },
+
+  Oportunidad = {
+    id | String,
+    cliente_id | String,
+    titulo | non_empty_string,
+    monto | positive_int,
+    currency | currency_iso,
+    etapa | etapa_pipeline,
+    timestamp | String,
+  },
+
+  Interaccion = {
+    id | String,
+    cliente_id | String,
+    canal | canal_interaccion,
+    nota | String,
+    timestamp | String,
+  },
+}
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index ff842e9..695fa1b 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -2,7 +2,31 @@
 
 ERP categórico.
 
-### fix(nakui-core): schema_bundle_hash debe reflejar el contenido real del schema
+### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones
+
+Módulo CRM funcional, declarativo como inventory/sales/treasury
+(`crates/modules/nakui/modules/crm/`): `schema.ncl` + `nsmc.json` +
+morfismos Rhai. Tres entities — `Cliente`, `Oportunidad`, `Interaccion`
+— y tres morfismos:
+
+- `abrir_oportunidad(cliente)` — crea una `Oportunidad` en etapa
+  `prospecto`.
+- `mover_oportunidad(oportunidad)` — avanza el pipeline
+  (prospecto→calificado→propuesta→negociacion→ganada/perdida). Valida:
+  etapa destino conocida, no mover una oportunidad cerrada, no retroceder.
+- `registrar_interaccion(cliente)` — crea una `Interaccion`
+  (llamada/email/reunión).
+
+- `core/src/bin/crm_demo.rs` — demo realista (3 clientes, 18 eventos).
+  A diferencia de los otros demos **no borra el event log**: lo deja en
+  disco e imprime el comando para abrirlo con `nakui-explorer` — así el
+  explorador, que sólo mostraba un log vacío, muestra un CRM con cuerpo.
+- `core/tests/crm.rs` — 8 tests de integración (pipeline completo +
+  rechazos: retroceso, oportunidad cerrada, etapa/canal/monto inválidos).
+
+Nota: el engine Rhai de `nakui-core` es `Engine::new_raw()` con paquetes
+selectos — `Array::index_of` no resuelve; los morfismos usan sólo
+`if`/`==`/`throw`.
 Iter 17. Regresión surfaceada por el workspace test
 `verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a
 `verify_log_rejects_seed_after_schema_changes`).