gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply)

Cierra el ultimo gran TODO de la metainterfaz Nakui: las acciones
Action::Morphism ya no son un toast informativo; despachan al
Executor cargado del manifest nakui-core (nsmc.json + schemas KCL +
scripts Rhai), pasando por el pipeline completo: compute (con
dry-run + KCL post-checks) -> log append -> store apply.

Schema nakui-ui-schema extendido:
- Module.nakui_module_dir: Option<String> nuevo. Path al modulo
  nakui-core. Sin esto, Action::Morphism quedan no-op con toast.
  SeedEntity sigue funcionando (alta administrativa sin manifest).
- Action::Morphism gano dos campos opcionales:
  - inputs: BTreeMap<String, String> — mapeo role -> field_name.
  - params: Vec<String> — fields cuyos values van al params JSON.
    Si vacio, todos los fields no-input van a params.

Runtime nakui-ui:
- MetaUi.executors: BTreeMap<String, Arc<Executor>> nuevo. Carga
  Executor::load_module(nakui_module_dir) en MetaUi::new.
- commit_morphism: resuelve inputs (parsea UUIDs), arma params
  (Value object con tipos inferidos), llama
  execute_and_log_with_recovery. Toast con count de ops o error.
- infer_param_value: heuristica i64 -> f64 -> bool -> string.

Tests: 2 nuevos. E2E morphism_pipeline_executes_real_sales_vender
carga el modulo real crates/modules/nakui/modules/sales, ejecuta
"vender" con inputs Stock+Caja y params (cantidad=5, precio=200,
venta_id, timestamp). Asserta:
- el morphism produce ops (no vacio).
- stock.cantidad: 100 -> 95.
- caja.saldo: 1_000_000 -> 1_001_000.

12 tests verdes en nakui-ui (+1). Schema extension no rompio nada
(6 unit + 5 integration siguen verdes).

Demo nuevo: examples/nakui-modules/sales_engine/module.json apunta
al sales real via nakui_module_dir. 6 vistas (list+form para Stock/
Caja/Venta + "Vender" con Action::Morphism). El user crea Stocks +
Cajas con seed_entity, copia los UUIDs a los inputs de "Vender", y
ejecuta el morphism real con KCL post-checks.

Activacion:
  NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
  NAKUI_MODULES_DIR=examples/nakui-modules \\
  cargo run -p nakui-ui

Trade-offs:
- Inputs UUID a mano (no dropdown). Nice-to-have: FieldKind::EntityRef
  que renderee selector.
- Inferencia de tipo en params es heuristica.

932e7464d7574f32694619763f4561f17b63e5e7 · by Sergio · (5 months ago) · parent 170d1f890 · browse the tree at this commit

Semantic delta · 13

crates/apps/nakui-ui/src/main.rs

crates/modules/nakui/ui-schema/src/lib.rs

crates/modules/nakui/ui-schema/tests/example_modules.rs

5 files · +601 −8

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac73526..360382f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,88 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
+Cierra el último gran TODO de la metainterfaz Nakui: las acciones
+`Action::Morphism` ya no son un toast informativo; despachan al
+`Executor` cargado del manifest nakui-core (`nsmc.json` + schemas
+KCL + scripts Rhai), pasando por el pipeline completo de Nakui:
+compute (con dry-run + KCL post-checks) → log append → store apply.
+
+Schema `nakui-ui-schema` extendido:
+- **`Module.nakui_module_dir: Option<String>`** nuevo. Path
+  (relativo al directorio del `module.json` o absoluto) a un módulo
+  nakui-core. Sin esto, las Action::Morphism del módulo quedan
+  no-op con toast informativo. Las Action::SeedEntity siguen
+  funcionando sin manifest (alta administrativa).
+- **`Action::Morphism`** ganó dos campos opcionales:
+  - `inputs: BTreeMap<String, String>` — mapeo `role → field_name`.
+    Por cada input declarado en el `MorphismSpec.inputs`, indica
+    qué field del form contiene el UUID del record. El runtime
+    parsea como `Uuid` y lo pasa al `execute_and_log`.
+  - `params: Vec<String>` — lista de fields cuyos values van al
+    `params` JSON. Si vacío, todos los fields no-input van a params.
+
+Runtime `nakui-ui`:
+- **`MetaUi.executors: BTreeMap<String, Arc<Executor>>`** nuevo.
+  Carga `Executor::load_module(nakui_module_dir)` en `MetaUi::new`
+  por cada módulo UI que declare la entry. Errores de carga van al
+  banner; el módulo sigue cargado para SeedEntity, sólo Morphism
+  queda no-op.
+- **`commit_morphism(mod_idx, name, inputs_map, params_fields)`** nuevo.
+  Resuelve inputs (parsea cada field como Uuid), arma params (Value
+  object con tipos inferidos via `infer_param_value` — int/float/
+  bool/string), llama `execute_and_log_with_recovery`. Toast con
+  cantidad de ops aplicadas o el error tipado.
+- **`infer_param_value`** nuevo helper: heurística simple para
+  pasar values del form al morphism con tipo inferido (i64 → f64 →
+  bool → string).
+
+Tests: 2 nuevos:
+- `infer_param_value_int_then_float_then_bool_then_string` —
+  cobertura de la heurística.
+- **E2E `morphism_pipeline_executes_real_sales_vender`** —
+  carga el módulo real `crates/modules/nakui/modules/sales`,
+  arma store + log, ejecuta el morphism `vender` con inputs
+  Stock+Caja y params (cantidad=5, precio_unitario=200,
+  venta_id, timestamp). Asserta:
+  - el morphism produce ops (no vacío).
+  - stock.cantidad bajó 100 → 95.
+  - caja.saldo subió 1_000_000 → 1_001_000.
+
+12 tests verdes en nakui-ui (+1 vs commit anterior). Schema
+extension no rompió nada (6 unit + 5 integration siguen verdes).
+
+Demo nuevo: **`examples/nakui-modules/sales_engine/module.json`**
+- Apunta a `crates/modules/nakui/modules/sales` vía `nakui_module_dir`.
+- 6 vistas: list + form para cada Stock, Caja, Venta + form
+  "Vender" con `Action::Morphism { name: "vender", inputs: {stock,
+  caja}, params: [venta_id, cantidad, precio_unitario, timestamp] }`.
+- El user crea Stocks + Cajas con seed_entity, copia los UUIDs
+  cortos a los inputs de "Vender", y ejecuta el morphism real:
+  stock baja, caja sube, Venta se persiste, todo loggeado.
+- Validaciones KCL fallan limpio (toast con error) si el morphism
+  rebota — p. ej. cantidad > stock disponible.
+
+Activación full:
+```sh
+NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
+NAKUI_MODULES_DIR=examples/nakui-modules \
+cargo run -p nakui-ui
+# Sidebar gana "Ventas (con morphism)" — los 6 menús aparecen y
+# el form "Vender" dispara el pipeline nakui-core completo.
+```
+
+Trade-offs documentados:
+- **Inputs UUID a mano**: el form pide que el user copie el UUID de
+  un Stock/Caja existente. Para UX seria habría que agregar
+  `FieldKind::EntityRef { entity }` que renderiza un dropdown — no
+  hecho por scope, queda como nice-to-have.
+- **Inferencia de tipo en params**: `infer_param_value` adivina
+  por shape del string. Para casos sutiles (ej. "true" como string
+  literal vs bool), el módulo nakui-core puede explicitar tipos
+  via `kind` en el FieldSpec — el form lo respeta para validación
+  pre-submit; la inferencia final sigue siendo heurística.
+
 ### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
 Cierra "no hay UI para editar/borrar records existentes" del commit
 anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 56f4b2a..0a1f1ab 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -31,7 +31,8 @@ use gpui::{
     Render, SharedString, Window, WindowBounds, WindowOptions,
 };
 use nakui_core::delta::{FieldOp, FieldPath};
-use nakui_core::event_log::{replay_into, EventLog, LogEntry};
+use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry};
+use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
 use nakui_ui_schema::{
     Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
@@ -75,6 +76,11 @@ struct MetaUi {
     /// log falló — en ese caso el runtime degrada a in-memory only y
     /// loggea un toast informativo.
     event_log: Option<Arc<Mutex<EventLog>>>,
+    /// Executors nakui cargados, indexados por `module.id`. Sólo
+    /// existen los módulos que declaran `nakui_module_dir`. Las
+    /// acciones `Morphism` requieren que el módulo activo tenga
+    /// uno; sin él, despachan un toast informativo.
+    executors: BTreeMap<String, Arc<Executor>>,
     /// (módulo idx, vista key) actualmente activos.
     active: Option<(usize, String)>,
     /// Inputs vivos para el form actual: nombre del campo → TextInput.
@@ -169,6 +175,43 @@ impl MetaUi {
             }
         };
 
+        // Cargar Executors para los módulos que declararon
+        // `nakui_module_dir`. Resolvemos paths relativos al
+        // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
+        // pwd. Cualquier error de carga deja la entry afuera y
+        // anota al banner — Action::Morphism queda no-op para ese
+        // módulo pero el resto sigue funcionando.
+        let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
+        for m in &modules {
+            let Some(rel) = &m.nakui_module_dir else {
+                continue;
+            };
+            let module_root = modules_dir.join(&m.id);
+            let nakui_dir = if std::path::Path::new(rel).is_absolute() {
+                PathBuf::from(rel)
+            } else {
+                module_root.join(rel)
+            };
+            match Executor::load_module(&nakui_dir) {
+                Ok(exec) => {
+                    executors.insert(m.id.clone(), Arc::new(exec));
+                }
+                Err(e) => {
+                    let msg = format!(
+                        "módulo {}: no pude cargar executor nakui en {}: {e}",
+                        m.id,
+                        nakui_dir.display()
+                    );
+                    match &load_error {
+                        Some(prev) => {
+                            load_error = Some(SharedString::from(format!("{prev}; {msg}")));
+                        }
+                        None => load_error = Some(SharedString::from(msg)),
+                    }
+                }
+            }
+        }
+
         let active = modules
             .first()
             .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
@@ -177,6 +220,7 @@ impl MetaUi {
             modules,
             store: Arc::new(Mutex::new(store)),
             event_log,
+            executors,
             active,
             form_inputs: BTreeMap::new(),
             editing: None,
@@ -334,15 +378,136 @@ impl MetaUi {
                 }
                 cx.notify();
             }
-            Action::Morphism { name, .. } => {
-                self.toast = Some(SharedString::from(format!(
-                    "morphism '{name}': pendiente (requiere manifest nakui)"
-                )));
+            Action::Morphism {
+                name,
+                inputs,
+                params,
+                next_view,
+            } => {
+                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
+                    Ok(op_count) => {
+                        self.toast = Some(SharedString::from(format!(
+                            "morphism '{name}' OK ({op_count} op(s) aplicadas)"
+                        )));
+                        if let Some(v) = next_view {
+                            self.select_view(mod_idx, v, cx);
+                        }
+                    }
+                    Err(e) => {
+                        self.toast = Some(SharedString::from(format!(
+                            "morphism '{name}' falló: {e}"
+                        )));
+                    }
+                }
                 cx.notify();
             }
         }
     }
 
+    /// Despacha un morphism al pipeline real de nakui-core: lee
+    /// inputs (UUIDs) + params (Value object) del form, llama
+    /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
+    /// que el morphism produjo (para feedback).
+    fn commit_morphism(
+        &mut self,
+        mod_idx: usize,
+        morphism: &str,
+        inputs_map: &BTreeMap<String, String>,
+        params_fields: &[String],
+        cx: &mut Context<Self>,
+    ) -> Result<usize, String> {
+        let _ = cx;
+        let module = self
+            .modules
+            .get(mod_idx)
+            .ok_or_else(|| "módulo inválido".to_string())?;
+        let executor = self
+            .executors
+            .get(&module.id)
+            .ok_or_else(|| {
+                format!(
+                    "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
+                    module.id
+                )
+            })?
+            .clone();
+        let log_arc = self
+            .event_log
+            .as_ref()
+            .ok_or_else(|| "morphism requiere event log activo".to_string())?
+            .clone();
+
+        // Resolver inputs: por cada (role, field_name), parsear el
+        // value del input como Uuid.
+        let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
+        for (role, field_name) in inputs_map {
+            let raw = self
+                .form_inputs
+                .get(field_name)
+                .map(|inp| inp.read(&*cx).text().to_string())
+                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
+            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
+                format!(
+                    "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
+                )
+            })?;
+            input_pairs.push((role.clone(), id));
+        }
+
+        // Resolver params: si la lista está vacía, todos los fields
+        // del form que no estén en `inputs_map` van a params. Si
+        // hay lista, sólo esos.
+        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
+        let mut params_obj = serde_json::Map::new();
+        let field_iter: Vec<String> = if params_fields.is_empty() {
+            self.form_inputs
+                .keys()
+                .filter(|k| !input_field_set.contains(*k))
+                .cloned()
+                .collect()
+        } else {
+            params_fields.to_vec()
+        };
+        for field_name in field_iter {
+            let raw = self
+                .form_inputs
+                .get(&field_name)
+                .map(|inp| inp.read(&*cx).text().to_string())
+                .unwrap_or_default();
+            // Inferencia ligera: número si parsea, bool en
+            // true/false, string en cualquier otro caso. Coherente
+            // con el modelo "el morphism Rhai espera tipos", pero
+            // simple — para casos finos, el caller puede declarar
+            // `kind: Number` en el FieldSpec, y el form lo respeta.
+            let value = infer_param_value(&raw);
+            params_obj.insert(field_name, value);
+        }
+
+        let inputs_ref: Vec<(&str, Uuid)> = input_pairs
+            .iter()
+            .map(|(r, id)| (r.as_str(), *id))
+            .collect();
+
+        let mut log = log_arc
+            .lock()
+            .map_err(|_| "log mutex envenenado".to_string())?;
+        let mut store = self
+            .store
+            .lock()
+            .map_err(|_| "store mutex envenenado".to_string())?;
+
+        let ops = execute_and_log_with_recovery(
+            &executor,
+            &mut *store,
+            &mut *log,
+            morphism,
+            &inputs_ref,
+            Value::Object(params_obj),
+        )
+        .map_err(|e| format!("{e}"))?;
+        Ok(ops.len())
+    }
+
     /// Construye un Value desde los TextInput vivos y lo seedea al store.
     fn commit_seed(
         &mut self,
@@ -501,6 +666,31 @@ fn render_value(v: Option<&Value>) -> String {
     }
 }
 
+/// Inferencia de tipo para values pasados como `params` a un
+/// morphism. Usada cuando el form no declara `FieldKind` explícito
+/// (Action::Morphism toma `params: Vec<String>` con sólo los nombres,
+/// no los kinds).
+///
+/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
+/// resto → string.
+fn infer_param_value(raw: &str) -> Value {
+    if raw.is_empty() {
+        return Value::Null;
+    }
+    if let Ok(i) = raw.parse::<i64>() {
+        return json!(i);
+    }
+    if let Ok(f) = raw.parse::<f64>() {
+        return json!(f);
+    }
+    match raw {
+        "true" => return json!(true),
+        "false" => return json!(false),
+        _ => {}
+    }
+    json!(raw)
+}
+
 /// Conversión inversa a `parse_field_value`: del JSON al texto raw
 /// que un input puede tomar y volver a parsearse igual al submit.
 /// Usado para pre-llenar inputs en modo edit.
@@ -1049,6 +1239,16 @@ mod tests {
         assert!(lookup_field(&v, "address.zipcode").is_none());
     }
 
+    #[test]
+    fn infer_param_value_int_then_float_then_bool_then_string() {
+        assert_eq!(infer_param_value(""), json!(null));
+        assert_eq!(infer_param_value("42"), json!(42));
+        assert_eq!(infer_param_value("3.14"), json!(3.14));
+        assert_eq!(infer_param_value("true"), json!(true));
+        assert_eq!(infer_param_value("false"), json!(false));
+        assert_eq!(infer_param_value("hola"), json!("hola"));
+    }
+
     #[test]
     fn render_value_handles_null_string_bool() {
         assert_eq!(render_value(None), "");
@@ -1153,6 +1353,106 @@ mod tests {
         let _ = std::fs::remove_file(&path);
     }
 
+    /// E2E del Action::Morphism: carga el módulo nakui-core real
+    /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
+    /// arma store + log, y ejecuta el morphism `vender` vía
+    /// `execute_and_log_with_recovery` (la misma función que
+    /// `commit_morphism` invoca). Verifica que las ops esperadas
+    /// se loguean y aplican (stock decrementa, caja incrementa).
+    ///
+    /// Reproduce el flujo del runtime sin necesitar GPUI.
+    #[test]
+    fn morphism_pipeline_executes_real_sales_vender() {
+        use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
+        use nakui_core::executor::Executor;
+        use nakui_core::store::{MemoryStore, Store};
+
+        // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
+        // → crates/modules/nakui/modules/sales).
+        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+        let sales_dir = here
+            .join("../../..")
+            .join("crates/modules/nakui/modules/sales");
+        if !sales_dir.join("nsmc.json").exists() {
+            // Si el módulo demo no está donde esperamos, skipeamos
+            // — no es regresión del feature, es ambiente.
+            eprintln!(
+                "skip: sales module no encontrado en {}",
+                sales_dir.display()
+            );
+            return;
+        }
+
+        let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
+
+        let mut store = MemoryStore::new();
+        let stock_id = Uuid::new_v4();
+        let caja_id = Uuid::new_v4();
+        store.seed(
+            "Stock",
+            stock_id,
+            json!({
+                "id": stock_id.to_string(),
+                "sku_id": "test-sku",
+                "ubicacion": "loc-1",
+                "cantidad": 100_i64,
+            }),
+        );
+        store.seed(
+            "Caja",
+            caja_id,
+            json!({
+                "id": caja_id.to_string(),
+                "name": "Caja Test",
+                "currency": "USD",
+                "saldo": 1_000_000_i64,
+            }),
+        );
+
+        let tmp = tempfile::NamedTempFile::new().unwrap();
+        let log_path = tmp.path().to_path_buf();
+        drop(tmp);
+        let mut log = EventLog::open(&log_path).unwrap();
+
+        let venta_id = Uuid::new_v4();
+        let inputs = vec![("stock", stock_id), ("caja", caja_id)];
+        let params = json!({
+            "venta_id": venta_id.to_string(),
+            "cantidad": 5_i64,
+            "precio_unitario": 200_i64,
+            "timestamp": "2026-05-04T10:00:00Z",
+        });
+
+        let ops = execute_and_log_with_recovery(
+            &executor,
+            &mut store,
+            &mut log,
+            "vender",
+            &inputs,
+            params,
+        )
+        .expect("morphism vender debe ejecutar limpio");
+
+        assert!(!ops.is_empty(), "vender debería producir ops");
+
+        // Sanity post-condiciones esperadas del manifest sales:
+        // - stock.cantidad bajó (vendimos 5).
+        let stock_after = store
+            .load("Stock", stock_id)
+            .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
+            .expect("stock con cantidad");
+        assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
+        // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
+        // inicial 1_000_000).
+        let caja_after = store
+            .load("Caja", caja_id)
+            .and_then(|v| v.get("saldo").and_then(Value::as_i64))
+            .expect("caja con saldo");
+        assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
+
+        let _ = std::fs::remove_file(&log_path);
+    }
+
     /// E2E del ciclo CRUD vía log:
     /// 1. Seed un record.
     /// 2. Morphism con Set ops (edit) — sobreescribe campos.
diff --git a/crates/modules/nakui/ui-schema/src/lib.rs b/crates/modules/nakui/ui-schema/src/lib.rs
index d180822..0f28f69 100644
--- a/crates/modules/nakui/ui-schema/src/lib.rs
+++ b/crates/modules/nakui/ui-schema/src/lib.rs
@@ -66,6 +66,22 @@ pub struct Module {
     #[serde(default)]
     pub entities: Vec<EntitySpec>,
 
+    /// Path a un módulo nakui-core (directorio con `nsmc.json` +
+    /// schemas KCL + scripts Rhai). Cuando está set, el runtime
+    /// carga un `Executor` para ese path y permite que las acciones
+    /// `Morphism { name }` despachen al pipeline real
+    /// (compute → log → apply).
+    ///
+    /// Path resuelto relativo al directorio del `module.json`
+    /// o absoluto.
+    ///
+    /// Si es `None`, las acciones `Morphism` quedan deshabilitadas
+    /// (toast informativo al usuario). Las acciones `SeedEntity`
+    /// siguen funcionando sin esto — son altas administrativas que
+    /// no necesitan validación de manifest.
+    #[serde(default)]
+    pub nakui_module_dir: Option<String>,
+
     /// Items del menú. Cada uno apunta a una key de `views`. Orden
     /// importa (es el orden en que se presentan en el sidebar).
     pub menu: Vec<MenuItem>,
@@ -199,9 +215,27 @@ pub enum Action {
         next_view: Option<String>,
     },
     /// Ejecuta un morphism declarado en el manifest del módulo
-    /// nakui-core. Inputs y params se mapean desde los campos del form.
+    /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
+    /// Inputs (records existentes) y params (valores escalares) se
+    /// mapean desde los campos del form.
     Morphism {
+        /// Nombre del morphism declarado en `nsmc.json` del manifest
+        /// nakui apuntado por el módulo.
         name: String,
+        /// Mapeo `role → field_name`: por cada input declarado en
+        /// el `MorphismSpec.inputs`, indica qué field del form
+        /// contiene el UUID del record. El runtime parsea el value
+        /// como `Uuid` y lo pasa como input al `execute_and_log`.
+        ///
+        /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
+        /// morphism `vender` que toma roles `stock` y `caja`.
+        #[serde(default)]
+        inputs: BTreeMap<String, String>,
+        /// Lista de fields del form cuyos values van al `params`
+        /// JSON object pasado al morphism. Si está vacío, todos los
+        /// fields que no estén en `inputs` van a params.
+        #[serde(default)]
+        params: Vec<String>,
         #[serde(default)]
         next_view: Option<String>,
     },
@@ -329,6 +363,7 @@ mod tests {
             id: "customers".into(),
             label: "Clientes".into(),
             description: Some("Gestión de clientes".into()),
+            nakui_module_dir: None,
             entities: vec![EntitySpec {
                 name: "customer".into(),
                 label: "Cliente".into(),
diff --git a/crates/modules/nakui/ui-schema/tests/example_modules.rs b/crates/modules/nakui/ui-schema/tests/example_modules.rs
index 1ed8408..1e552c1 100644
--- a/crates/modules/nakui/ui-schema/tests/example_modules.rs
+++ b/crates/modules/nakui/ui-schema/tests/example_modules.rs
@@ -14,7 +14,7 @@ fn examples_dir() -> std::path::PathBuf {
 }
 
 #[test]
-fn loads_all_six_demo_modules() {
+fn loads_all_demo_modules() {
     let dir = examples_dir();
     let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
         panic!("load failed for {}: {e}", dir.display());
@@ -27,10 +27,37 @@ fn loads_all_six_demo_modules() {
             "inventory_movements",
             "invoices",
             "products",
+            "sales_engine",
             "sales_orders",
             "suppliers",
         ],
-        "expected 6 modules in alphabetical order"
+        "expected 7 modules in alphabetical order \
+         (sales_engine se sumó al wirear Action::Morphism)"
+    );
+}
+
+#[test]
+fn sales_engine_declares_nakui_module_dir_and_morphism() {
+    // Sanity del módulo demo de morphism: nakui_module_dir set,
+    // y al menos una vista con Action::Morphism en su on_submit.
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    let sales = mods
+        .iter()
+        .find(|m| m.id == "sales_engine")
+        .expect("sales_engine debe estar");
+    assert!(
+        sales.nakui_module_dir.is_some(),
+        "sales_engine debería declarar nakui_module_dir"
+    );
+    let has_morphism_view = sales.views.values().any(|v| match v {
+        nakui_ui_schema::View::Form(form) => {
+            matches!(form.on_submit, nakui_ui_schema::Action::Morphism { .. })
+        }
+        _ => false,
+    });
+    assert!(
+        has_morphism_view,
+        "sales_engine debería tener al menos una Form con Action::Morphism"
     );
 }
 
diff --git a/examples/nakui-modules/sales_engine/module.json b/examples/nakui-modules/sales_engine/module.json
new file mode 100644
index 0000000..1a0189b
--- /dev/null
+++ b/examples/nakui-modules/sales_engine/module.json
@@ -0,0 +1,149 @@
+{
+  "id": "sales_engine",
+  "label": "Ventas (con morphism)",
+  "description": "Módulo conectado al manifest nakui-core 'sales': el form 'Vender' dispara el morphism que valida + mueve stock y caja con KCL post-checks reales.",
+  "nakui_module_dir": "../../../crates/modules/nakui/modules/sales",
+  "entities": [
+    {
+      "name": "Stock",
+      "label": "Stock",
+      "fields": [
+        { "name": "id", "label": "ID", "kind": "text" },
+        { "name": "sku_id", "label": "SKU", "kind": "text", "required": true },
+        { "name": "ubicacion", "label": "Ubicación", "kind": "text" },
+        { "name": "cantidad", "label": "Cantidad", "kind": "number", "required": true }
+      ]
+    },
+    {
+      "name": "Caja",
+      "label": "Caja",
+      "fields": [
+        { "name": "id", "label": "ID", "kind": "text" },
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+        { "name": "saldo", "label": "Saldo", "kind": "number", "default": "0" }
+      ]
+    },
+    {
+      "name": "Venta",
+      "label": "Venta",
+      "fields": [
+        { "name": "id", "label": "ID", "kind": "text" },
+        { "name": "stock_id", "label": "Stock ref", "kind": "text" },
+        { "name": "caja_id", "label": "Caja ref", "kind": "text" },
+        { "name": "cantidad", "label": "Cantidad vendida", "kind": "number" },
+        { "name": "precio_unitario", "label": "Precio unit.", "kind": "number" },
+        { "name": "total", "label": "Total", "kind": "number" }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Stock", "view": "stock_list", "icon": "📦" },
+    { "label": "+ Stock", "view": "stock_form", "icon": "✚" },
+    { "label": "Cajas", "view": "caja_list", "icon": "💰" },
+    { "label": "+ Caja", "view": "caja_form", "icon": "✚" },
+    { "label": "Ventas registradas", "view": "venta_list", "icon": "🧾" },
+    { "label": "Vender", "view": "vender_form", "icon": "⚡" }
+  ],
+  "views": {
+    "stock_list": {
+      "kind": "list",
+      "title": "Stock",
+      "entity": "Stock",
+      "columns": [
+        { "field": "sku_id", "label": "SKU", "weight": 1.5 },
+        { "field": "ubicacion", "label": "Ubicación", "weight": 1.5 },
+        { "field": "cantidad", "label": "Cantidad", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "stock_form", "label": "✚ Stock" }
+      ],
+      "search_in": ["sku_id", "ubicacion"]
+    },
+    "stock_form": {
+      "kind": "form",
+      "title": "Nuevo stock",
+      "entity": "Stock",
+      "fields": [
+        { "name": "id", "label": "ID interno", "kind": "text", "help": "El UUID lo genera el runtime; este field es para el campo `id` de la entity (Nakui lo usa)." },
+        { "name": "sku_id", "label": "SKU", "kind": "text", "required": true },
+        { "name": "ubicacion", "label": "Ubicación", "kind": "text", "required": true, "default": "loc-default" },
+        { "name": "cantidad", "label": "Cantidad inicial", "kind": "number", "required": true, "default": "0" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "Stock",
+        "next_view": "stock_list"
+      }
+    },
+    "caja_list": {
+      "kind": "list",
+      "title": "Cajas",
+      "entity": "Caja",
+      "columns": [
+        { "field": "name", "label": "Nombre", "weight": 2.0 },
+        { "field": "currency", "label": "Moneda", "weight": 0.5 },
+        { "field": "saldo", "label": "Saldo", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "caja_form", "label": "✚ Caja" }
+      ],
+      "search_in": ["name", "currency"]
+    },
+    "caja_form": {
+      "kind": "form",
+      "title": "Nueva caja",
+      "entity": "Caja",
+      "fields": [
+        { "name": "id", "label": "ID interno", "kind": "text" },
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+        { "name": "saldo", "label": "Saldo inicial", "kind": "number", "default": "0" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "Caja",
+        "next_view": "caja_list"
+      }
+    },
+    "venta_list": {
+      "kind": "list",
+      "title": "Ventas",
+      "entity": "Venta",
+      "columns": [
+        { "field": "stock_id", "label": "Stock ref", "weight": 1.0 },
+        { "field": "caja_id", "label": "Caja ref", "weight": 1.0 },
+        { "field": "cantidad", "label": "Cant.", "weight": 0.6 },
+        { "field": "precio_unitario", "label": "Precio", "weight": 0.8 },
+        { "field": "total", "label": "Total", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "vender_form", "label": "⚡ Vender" }
+      ],
+      "search_in": ["stock_id", "caja_id"]
+    },
+    "vender_form": {
+      "kind": "form",
+      "title": "Vender (morphism)",
+      "entity": "Venta",
+      "fields": [
+        { "name": "stock_id_input", "label": "Stock UUID", "kind": "text", "required": true, "help": "Copiá el UUID corto de la lista de Stock — el runtime lo parsea como Uuid completo si es válido." },
+        { "name": "caja_id_input", "label": "Caja UUID", "kind": "text", "required": true, "help": "Idem para Caja." },
+        { "name": "venta_id", "label": "Venta UUID (idempotencia)", "kind": "text", "required": true, "help": "UUID nuevo por cada intento; mismo UUID = idempotente." },
+        { "name": "cantidad", "label": "Cantidad a vender", "kind": "number", "required": true, "default": "1" },
+        { "name": "precio_unitario", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
+        { "name": "timestamp", "label": "Timestamp ISO", "kind": "text", "required": true, "default": "2026-05-09T12:00:00Z" }
+      ],
+      "on_submit": {
+        "kind": "morphism",
+        "name": "vender",
+        "inputs": {
+          "stock": "stock_id_input",
+          "caja": "caja_id_input"
+        },
+        "params": ["venta_id", "cantidad", "precio_unitario", "timestamp"],
+        "next_view": "venta_list"
+      }
+    }
+  }
+}