gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime

Sigue de la Fase 1 (lift del schema). Ahora los helpers puros que
cualquier widget renderer o backend ejecutor consume sobre el schema
viven en yahweh-meta-runtime. Sin GPUI, sin nakui — usa cierres en
lugar de traits para decoupling máximo.

Crate nuevo crates/modules/ui_engine/libs/meta-runtime:
- parse.rs: parse_field_value, infer_param_value, resolve_param_value.
- delta.rs: compute_field_delta, compute_clear_fields.
- refs.rs: validate_entity_refs(load: F, refs) con cierre
  Fn(&str, Uuid) -> Option<Value> en vez de trait Store.
- format.rs: human_label_for_record, render_value, value_to_input_text,
  short_uuid.
- 33 tests propios.

nakui-ui:
- Nueva dep yahweh-meta-runtime.
- Borrado código local equivalente (~200 líneas) + 34 tests
  duplicados.
- validate_entity_refs callsite usa cierre:
  validate_entity_refs(|e, id| store.load(e, id), &refs).
- 14 tests runtime-específicos quedan (compact/snapshot/event-log/
  morphism pipeline/load_ui_modules).

Distribución tests: 48 → 14 nakui-ui; +33 yahweh-meta-runtime.
Cada crate afectado builds + tests limpio individualmente. Workspace
build full no completó esta corrida por OOM al compilar
surrealdb-core (ambiental, no relacionado).

Fase 2b pendiente: extraer render widgets (form/list/modal/
EntityRef selector) a yahweh — requiere diseñar MetaBackend trait.

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

61044844980dd4f8505d9ba94600d9b92a85423e · by Sergio · (5 months ago) · parent f5987d9cf · browse the tree at this commit

Semantic delta · 101

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

crates/modules/ui_engine/libs/meta-runtime/src/lib.rs

crates/modules/ui_engine/libs/meta-runtime/src/refs.rs

11 files · +755 −592

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c24edfd..1cea0a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,94 @@ Registro cronológico de cambios sustantivos en el monorepo Brahman. Cada
 entrada lista las acciones concretas tras un commit; para detalles de
 ratio/diff ver `git show <sha>`.
 
-## 2026-05-09
+## 2026-05-10
+
+### refactor(yahweh): Fase 2 — extraer helpers puros a `yahweh-meta-runtime`
+Sigue de la Fase 1 (lift del schema a yahweh). Ahora extraemos los
+**helpers puros** que cualquier widget renderer o backend ejecutor
+necesita sobre el schema: parse, delta, validation, format. Sin
+GPUI, sin acoplamiento a un backend específico.
+
+Crate nuevo: `crates/modules/ui_engine/libs/meta-runtime/`
+(`yahweh-meta-runtime`):
+- **Deps**: `serde_json`, `thiserror`, `uuid`, `yahweh-meta-schema`.
+  NO GPUI, NO nakui.
+- **Módulos**:
+  - `parse.rs` — `parse_field_value(kind, raw)`,
+    `infer_param_value(raw)`, `resolve_param_value(name, raw, spec)`.
+  - `delta.rs` — `compute_field_delta(current, proposed)`,
+    `compute_clear_fields(current, to_clear)`.
+  - `refs.rs` — `validate_entity_refs(load: F, refs)` donde `F`
+    es un cierre `Fn(&str, Uuid) -> Option<Value>`. Decoupling vía
+    closure en lugar de trait — evita atar el crate a cualquier
+    backend específico (no hay `Store` trait acá), y los callers
+    pasan `|e, id| store.load(e, id)` trivialmente.
+  - `format.rs` — `human_label_for_record(value, id)`,
+    `render_value(opt_value)`, `value_to_input_text(value)`,
+    `short_uuid(id)`.
+- **33 tests propios** en el crate nuevo (cubren todos los helpers
+  movidos + edge cases).
+
+Cambios en `nakui-ui`:
+- **Nueva dep** `yahweh-meta-runtime` en `Cargo.toml`.
+- **Imports**: agrega `use yahweh_meta_runtime::{...}` con todos los
+  helpers extraídos. Borrado el código local equivalente
+  (~200 líneas).
+- **`validate_entity_refs` callsite**: pasa de
+  `validate_entity_refs(&*store, &refs)` a
+  `validate_entity_refs(|e, id| store.load(e, id), &refs)` — el
+  closure es ergonómico sobre cualquier `Store`.
+- **Tests duplicados borrados** (~34 tests que ahora viven en
+  `yahweh-meta-runtime`):
+  - `parse_field_*` (text/number/boolean variants)
+  - `infer_param_value_*`
+  - `delta_*` (5 tests)
+  - `clear_fields_*` (3 tests)
+  - `validate_entity_refs_*` (5 tests)
+  - `resolve_param_*` (6 tests)
+  - `parse_field_entity_ref_*` (4 tests)
+  - `human_label_*` (3 tests), `render_value_*`,
+    `value_to_input_text_inverse_of_parse`
+- **Tests que se quedan en nakui-ui** (runtime-específicos):
+  - `lookup_field_simple_and_nested` — helper local del list renderer.
+  - `append_compact_msg_handles_both_branches`,
+    `runtime_compact_cycle_resets_counter_after_threshold`,
+    `snapshot_path_for_replaces_extension`,
+    `maybe_compact_log_*` (3) — wiring de persistencia a EventLog.
+  - `load_ui_modules_via_brahman_cards_*` (3) — integración con el
+    brazo de cards.
+  - `value_to_input_then_parse_round_trip` — round-trip del par
+    `value_to_input_text + parse_field_value` (toca ambos lados).
+  - `event_log_replay_restores_memory_store`,
+    `morphism_pipeline_executes_real_sales_vender`,
+    `event_log_replay_handles_full_crud_cycle` — E2E nakui-core.
+
+Distribución de tests:
+- `nakui-ui`: 48 → 14 (los 34 movidos viven en runtime).
+- `yahweh-meta-runtime`: 33 (nuevos).
+- `yahweh-meta-schema`: 8 (sin cambio).
+- `brahman-cards`: 26 (sin cambio).
+- Total cubriendo el área: 81.
+
+Build: cada crate afectado compila y testea limpio individualmente.
+Workspace build full no se completó esta corrida por OOM al
+compilar `surrealdb-core` (problema ambiental no relacionado al
+refactor).
+
+Lo que NO hace Fase 2:
+- No mueve los widgets render (`render_form`/`render_list`/
+  `render_entity_ref_selector`/`render_confirm_delete_banner`) a
+  yahweh — eso es Fase 2b/3, requiere diseñar el `MetaBackend`
+  trait porque las render functions tocan el state de `MetaUi`
+  (form_inputs, pending_delete, executors).
+
+**Pendientes** (orden):
+1. **Fase 2b**: extraer widget render a un crate yahweh nuevo
+   (sugerencia: `yahweh-widget-meta-form`). Requiere diseñar
+   `MetaBackend` trait.
+2. **Fase 3**: thin shell — `nakui-ui` queda reducido a una impl
+   de backend wireada a `nakui-core`.
+3. **KCL → Nickel** + **card.k eliminado**.
 
 ### refactor(yahweh): Fase 1 — `nakui-ui-schema` → `yahweh-meta-schema`
 Primer paso del refactor yahweh. El schema de UI declarativa
diff --git a/Cargo.lock b/Cargo.lock
index 542cb0b..4caecf6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6399,6 +6399,7 @@ dependencies = [
  "serde_json",
  "tempfile",
  "uuid",
+ "yahweh-meta-runtime",
  "yahweh-meta-schema",
  "yahweh-theme",
  "yahweh-widget-text-input",
@@ -12830,6 +12831,16 @@ dependencies = [
  "yahweh-theme",
 ]
 
+[[package]]
+name = "yahweh-meta-runtime"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "thiserror 2.0.18",
+ "uuid",
+ "yahweh-meta-schema",
+]
+
 [[package]]
 name = "yahweh-meta-schema"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index a4e8e12..cd9e1cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,6 +53,7 @@ members = [
     "crates/modules/ui_engine/libs/theme",
     "crates/modules/ui_engine/libs/bus",
     "crates/modules/ui_engine/libs/meta-schema",
+    "crates/modules/ui_engine/libs/meta-runtime",
     "crates/modules/ui_engine/libs/providers/fs",
     "crates/modules/ui_engine/libs/providers/sqlite",
     "crates/modules/ui_engine/widgets/tree",
diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml
index 3a0c684..9fe6b12 100644
--- a/crates/apps/nakui-ui/Cargo.toml
+++ b/crates/apps/nakui-ui/Cargo.toml
@@ -8,6 +8,7 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd
 [dependencies]
 nakui-core = { path = "../../modules/nakui/core" }
 yahweh-meta-schema = { path = "../../modules/ui_engine/libs/meta-schema" }
+yahweh-meta-runtime = { path = "../../modules/ui_engine/libs/meta-runtime" }
 brahman-cards = { path = "../../core/brahman-cards" }
 yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" }
 yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 3f6b979..a62d504 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -37,6 +37,10 @@ use nakui_core::event_log::{
 use brahman_cards::CardBody;
 use nakui_core::executor::Executor;
 use nakui_core::store::{MemoryStore, Store};
+use yahweh_meta_runtime::{
+    compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
+    render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
+};
 use yahweh_meta_schema::{
     Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
 };
@@ -764,7 +768,10 @@ impl MetaUi {
                 .store
                 .lock()
                 .map_err(|_| "store mutex envenenado".to_string())?;
-            validate_entity_refs(&*store, &entity_refs)?;
+            // El helper de yahweh-meta-runtime es store-agnóstico —
+            // toma un cierre `Fn(&str, Uuid) -> Option<Value>` que
+            // wrappea el store concreto.
+            validate_entity_refs(|e, id| store.load(e, id), &entity_refs)?;
         }
         // Ramificación: si `editing` está set para esta entity, es un
         // edit de un record existente — emitimos Morphism con un
@@ -1029,121 +1036,9 @@ fn maybe_compact_log(
     )))
 }
 
-/// Valida que cada UUID en `refs` apunte a un record que realmente
-/// existe en el store bajo la entity esperada. Devuelve el primer
-/// error encontrado (fail-fast).
-///
-/// `refs` es una lista de `(label, target_entity, uuid)`. El label
-/// va al error message, así que conviene que sea legible (ej:
-/// `FieldSpec.label` en lugar de `FieldSpec.name`).
-///
-/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism
-/// no necesitan este check porque `Executor::compute` ya valida cada
-/// input via `store.load(...).ok_or(EntityMissing)` antes de correr
-/// el script Rhai.
-fn validate_entity_refs<S: Store>(
-    store: &S,
-    refs: &[(String, String, Uuid)],
-) -> Result<(), String> {
-    for (label, target, id) in refs {
-        if store.load(target, *id).is_none() {
-            return Err(format!(
-                "campo '{label}': record {} de '{target}' no existe en el store",
-                short_uuid(id)
-            ));
-        }
-    }
-    Ok(())
-}
-
-/// Decide cuáles fields del `to_clear` candidate list ameritan
-/// realmente un `FieldOp::Clear`: sólo los que existen en el current
-/// con un valor non-null. Para fields ausentes o ya null, Clear es
-/// no-op semántico (el post-state es el mismo) y dropearlos
-/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
-///
-/// Preserva el orden del input para que el log entry sea estable.
-fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
-    to_clear
-        .iter()
-        .filter(|f| match current.get(f.as_str()) {
-            None | Some(Value::Null) => false,
-            Some(_) => true,
-        })
-        .cloned()
-        .collect()
-}
-
-/// Calcula el delta entre el record actual y los valores propuestos
-/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
-///
-/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
-/// `current=Value::Null` (record no encontrado) hace que todos los
-/// campos del `proposed` sean considerados nuevos. Un campo del
-/// proposed que coincide con el del current se omite. Campos que
-/// están en current pero NO en proposed se preservan tal cual (el
-/// edit no los toca; ver el comentario en commit_seed sobre por qué
-/// no clearemos campos vacíos).
-fn compute_field_delta(
-    current: &Value,
-    proposed: &serde_json::Map<String, Value>,
-) -> serde_json::Map<String, Value> {
-    proposed
-        .iter()
-        .filter(|(field, value)| current.get(field.as_str()) != Some(*value))
-        .map(|(k, v)| (k.clone(), v.clone()))
-        .collect()
-}
-
-fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
-    match kind {
-        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
-        // EntityRef se almacena como string del UUID seleccionado.
-        // Validamos que parsee como UUID al submit — antes esto se
-        // chequeaba sólo para morphism inputs (línea ~540), pero un
-        // EntityRef como SEED field o como param de morphism caía
-        // de la heurística silenciosa. Ahora rebota con mensaje
-        // claro acá, antes de tocar el log o el morphism Rhai.
-        // El selector clickable garantiza UUIDs válidos en happy
-        // path; este check protege paste manual o garbage.
-        FieldKind::EntityRef => {
-            let trimmed = raw.trim();
-            Uuid::parse_str(trimmed).map_err(|_| {
-                format!("'{raw}' no es UUID válido (usá el selector de records)")
-            })?;
-            Ok(json!(trimmed))
-        }
-        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
-            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
-            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
-            other => Err(format!("'{other}' no es booleano")),
-        },
-        FieldKind::Number => {
-            if let Ok(i) = raw.parse::<i64>() {
-                Ok(json!(i))
-            } else if let Ok(f) = raw.parse::<f64>() {
-                Ok(json!(f))
-            } else {
-                Err(format!("'{raw}' no es número"))
-            }
-        }
-    }
-}
-
-/// Etiqueta humana para representar un record en el selector de
-/// EntityRef. Heurística: prefiere campos comunes en este orden:
-/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
-fn human_label_for_record(value: &Value, id: &Uuid) -> String {
-    for key in ["name", "label", "title", "sku", "sku_id"] {
-        if let Some(v) = value.get(key).and_then(Value::as_str) {
-            if !v.is_empty() {
-                return format!("{} ({})", v, short_uuid(id));
-            }
-        }
-    }
-    short_uuid(id)
-}
-
+/// Walker dentro de un `Value` por path con `.` como separador.
+/// Local porque sólo lo usa la lista renderer y no tiene tests
+/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
 fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
     let mut cur = v;
     for seg in path.split('.') {
@@ -1152,88 +1047,6 @@ fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
     Some(cur)
 }
 
-fn render_value(v: Option<&Value>) -> String {
-    match v {
-        None | Some(Value::Null) => String::new(),
-        Some(Value::String(s)) => s.clone(),
-        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
-        Some(Value::Number(n)) => n.to_string(),
-        Some(other) => other.to_string(),
-    }
-}
-
-/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
-/// del form. **Strict path**: si hay spec, valida `required` y parsea
-/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
-/// llegar al morphism). **Fallback path**: si no hay spec (param
-/// declarado en `Action::Morphism.params` que no aparece en
-/// `form.fields`), usa la heurística `infer_param_value` para no
-/// quedar atado a un schema mal-formado.
-///
-/// Errores tienen el label legible del spec, así el toast de la UI
-/// es interpretable.
-fn resolve_param_value(
-    field_name: &str,
-    raw: &str,
-    spec: Option<&FieldSpec>,
-) -> Result<Value, String> {
-    let Some(s) = spec else {
-        return Ok(infer_param_value(raw));
-    };
-
-    let label = if s.label.is_empty() { field_name } else { &s.label };
-
-    if s.required && raw.trim().is_empty() {
-        return Err(format!("param '{label}' es obligatorio y está vacío"));
-    }
-    if raw.is_empty() && !s.required {
-        return Ok(Value::Null);
-    }
-    parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
-}
-
-/// Inferencia de tipo para values pasados como `params` a un
-/// morphism. Usada como fallback en `resolve_param_value` cuando el
-/// param declarado en `Action::Morphism.params` no aparece en los
-/// `form.fields` (módulo mal-formado).
-///
-/// 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.
-fn value_to_input_text(v: &Value) -> String {
-    match v {
-        Value::Null => String::new(),
-        Value::String(s) => s.clone(),
-        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
-        Value::Number(n) => n.to_string(),
-        other => other.to_string(),
-    }
-}
-
-fn short_uuid(id: &Uuid) -> String {
-    id.to_string().chars().take(8).collect()
-}
-
 impl Render for MetaUi {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let bg = gpui::rgb(0x14171c);
@@ -1944,30 +1757,9 @@ impl MetaUi {
 mod tests {
     use super::*;
 
-    #[test]
-    fn parse_field_text_returns_string() {
-        assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
-    }
-
-    #[test]
-    fn parse_field_number_int_then_float() {
-        assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
-        assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
-    }
-
-    #[test]
-    fn parse_field_number_invalid_errors() {
-        assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
-    }
-
-    #[test]
-    fn parse_field_boolean_variants() {
-        assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
-        assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
-        assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
-        assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
-        assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
-    }
+    // NOTA: `parse_field_value` / `parse_field_*` viven y se testean
+    // en `yahweh-meta-runtime`. Tests duplicados aquí se borraron en
+    // la Fase 2 del refactor yahweh.
 
     #[test]
     fn lookup_field_simple_and_nested() {
@@ -1981,118 +1773,12 @@ 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"));
-    }
-
-    fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
-        FieldSpec {
-            name: name.into(),
-            label: name.into(),
-            kind,
-            default: None,
-            required,
-            help: None,
-            ref_entity: None,
-        }
-    }
-
-    fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
-        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
-    }
-
-    #[test]
-    fn delta_empty_when_all_fields_match() {
-        let current = json!({
-            "name": "Acme",
-            "saldo": 100_i64,
-            "currency": "USD",
-        });
-        let proposed = map(&[
-            ("name", json!("Acme")),
-            ("saldo", json!(100_i64)),
-            ("currency", json!("USD")),
-        ]);
-        let delta = compute_field_delta(&current, &proposed);
-        assert!(delta.is_empty(), "no-op edit debería dar delta vacío");
-    }
-
-    #[test]
-    fn delta_includes_only_changed_field() {
-        let current = json!({
-            "name": "Acme",
-            "saldo": 100_i64,
-            "currency": "USD",
-        });
-        // El usuario sólo cambió saldo.
-        let proposed = map(&[
-            ("name", json!("Acme")),
-            ("saldo", json!(200_i64)),
-            ("currency", json!("USD")),
-        ]);
-        let delta = compute_field_delta(&current, &proposed);
-        assert_eq!(delta.len(), 1, "sólo saldo debería estar en delta");
-        assert_eq!(delta.get("saldo"), Some(&json!(200_i64)));
-        assert!(!delta.contains_key("name"));
-        assert!(!delta.contains_key("currency"));
-    }
-
-    #[test]
-    fn delta_treats_missing_record_as_all_new() {
-        // Record no existe en el store (load → None → Value::Null).
-        // Todos los campos del proposed deberían entrar al delta.
-        let current = Value::Null;
-        let proposed = map(&[
-            ("name", json!("Acme")),
-            ("saldo", json!(0_i64)),
-        ]);
-        let delta = compute_field_delta(&current, &proposed);
-        assert_eq!(delta.len(), 2);
-    }
-
-    #[test]
-    fn delta_distinguishes_int_from_string_repr() {
-        // Sanity: si el form devuelve "100" como Number → json!(100_i64)
-        // y el store tiene json!(100), comparan iguales (PartialEq de
-        // Value normaliza). Si el store tuviera "100" string, NO igualan.
-        let current = json!({"qty": 100_i64});
-        let proposed = map(&[("qty", json!(100_i64))]);
-        assert!(compute_field_delta(&current, &proposed).is_empty());
-
-        let current_str = json!({"qty": "100"});
-        let proposed_int = map(&[("qty", json!(100_i64))]);
-        assert_eq!(
-            compute_field_delta(&current_str, &proposed_int).len(),
-            1,
-            "string '100' vs int 100 sí debería contar como cambio"
-        );
-    }
-
-    #[test]
-    fn delta_skips_fields_absent_from_proposed() {
-        // Si el form omite un field (porque el FieldSpec no lo
-        // declara), no lo deberíamos mencionar en el delta — el edit
-        // sólo toca los fields del form.
-        let current = json!({
-            "name": "Acme",
-            "saldo": 100_i64,
-            "internal_marker": "x",
-        });
-        let proposed = map(&[
-            ("name", json!("Acme")),
-            ("saldo", json!(150_i64)),
-        ]);
-        let delta = compute_field_delta(&current, &proposed);
-        assert_eq!(delta.len(), 1);
-        assert_eq!(delta.get("saldo"), Some(&json!(150_i64)));
-        assert!(!delta.contains_key("internal_marker"));
-    }
+    // `infer_param_value`, helpers `spec`/`map`, todos los tests
+    // delta_* / clear_fields_* / parse_field_* / resolve_param_* /
+    // human_label_* / render_value / value_to_input_text / validate_entity_refs_*
+    // viven en `yahweh-meta-runtime`. Borrados en Fase 2 — quedan acá
+    // sólo tests de funcionalidad runtime-específica (compact, snapshot,
+    // event log, morphism pipeline, load_ui_modules).
 
     #[test]
     fn append_compact_msg_handles_both_branches() {
@@ -2174,71 +1860,6 @@ mod tests {
         let _ = std::fs::remove_file(&snap_path);
     }
 
-    #[test]
-    fn validate_entity_refs_passes_when_all_records_exist() {
-        let mut store = MemoryStore::new();
-        let stock_id = Uuid::new_v4();
-        let caja_id = Uuid::new_v4();
-        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
-        store.seed("Caja", caja_id, json!({"name": "Principal"}));
-        let refs = vec![
-            ("Stock".into(), "Stock".into(), stock_id),
-            ("Caja".into(), "Caja".into(), caja_id),
-        ];
-        assert!(validate_entity_refs(&store, &refs).is_ok());
-    }
-
-    #[test]
-    fn validate_entity_refs_fails_on_first_missing() {
-        let mut store = MemoryStore::new();
-        let stock_id = Uuid::new_v4();
-        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
-        let missing_caja = Uuid::new_v4();
-        let refs = vec![
-            ("Stock".into(), "Stock".into(), stock_id),
-            ("Caja".into(), "Caja".into(), missing_caja),
-        ];
-        let err = validate_entity_refs(&store, &refs).unwrap_err();
-        assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}");
-        assert!(
-            err.contains(&short_uuid(&missing_caja)),
-            "msg debe incluir el UUID corto: {err}"
-        );
-    }
-
-    #[test]
-    fn validate_entity_refs_uses_label_not_entity_in_msg() {
-        // Si el FieldSpec.label es distinto de la entity (ej:
-        // "Stock origen" en lugar de "Stock"), el error debería usar
-        // el label legible.
-        let store = MemoryStore::new();
-        let id = Uuid::new_v4();
-        let refs = vec![("Stock origen".into(), "Stock".into(), id)];
-        let err = validate_entity_refs(&store, &refs).unwrap_err();
-        assert!(
-            err.contains("Stock origen"),
-            "msg debe incluir el label: {err}"
-        );
-    }
-
-    #[test]
-    fn validate_entity_refs_empty_list_is_ok() {
-        let store = MemoryStore::new();
-        assert!(validate_entity_refs(&store, &[]).is_ok());
-    }
-
-    #[test]
-    fn validate_entity_refs_distinguishes_target_from_other_entities() {
-        // Sanity: un UUID que existe bajo entity X pero NO bajo Y
-        // debería fallar la validación contra Y.
-        let mut store = MemoryStore::new();
-        let id = Uuid::new_v4();
-        store.seed("Customer", id, json!({"name": "Acme"}));
-        // Mismo UUID, target distinto.
-        let refs = vec![("Stock".into(), "Stock".into(), id)];
-        assert!(validate_entity_refs(&store, &refs).is_err());
-    }
-
     /// E2E del nuevo `load_ui_modules` que pasa por
     /// `brahman_cards::load_cards_from_dir`. Verifica:
     /// 1. UiModules cargados ordenados por id.
@@ -2335,44 +1956,6 @@ mod tests {
         assert!(err.contains("dup"), "msg debe nombrar el id: {err}");
     }
 
-    #[test]
-    fn clear_fields_skips_absent_and_null() {
-        let current = json!({
-            "name": "Acme",
-            "notes": "lorem",
-            "tag": null,
-        });
-        let to_clear = vec![
-            "name".to_string(),
-            "notes".to_string(),
-            "tag".to_string(),
-            "missing".to_string(),
-        ];
-        let actual = compute_clear_fields(&current, &to_clear);
-        assert_eq!(
-            actual,
-            vec!["name".to_string(), "notes".to_string()],
-            "tag (null) y missing (ausente) deberían filtrarse — Clear sería no-op"
-        );
-    }
-
-    #[test]
-    fn clear_fields_preserves_input_order() {
-        let current = json!({"a": 1, "b": 2, "c": 3});
-        let to_clear = vec!["c".to_string(), "a".to_string(), "b".to_string()];
-        let actual = compute_clear_fields(&current, &to_clear);
-        assert_eq!(actual, vec!["c", "a", "b"], "orden del input se preserva");
-    }
-
-    #[test]
-    fn clear_fields_empty_when_current_is_null() {
-        // Record no existe en el store (load → None → Value::Null
-        // upstream). Ningún clear debería emitirse.
-        let current = Value::Null;
-        let to_clear = vec!["name".to_string()];
-        assert!(compute_clear_fields(&current, &to_clear).is_empty());
-    }
-
     #[test]
     fn snapshot_path_for_replaces_extension() {
         use std::path::Path;
@@ -2520,160 +2103,6 @@ mod tests {
         let _ = std::fs::remove_file(&snap_path);
     }
 
-    #[test]
-    fn resolve_param_strict_number_parses_i64() {
-        let s = spec("qty", FieldKind::Number, true);
-        let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
-        assert_eq!(v, json!(42));
-    }
-
-    #[test]
-    fn resolve_param_strict_boolean_rejects_non_boolean() {
-        let s = spec("active", FieldKind::Boolean, true);
-        let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
-        assert!(err.contains("active"), "msg debe mencionar el label: {err}");
-        assert!(
-            err.to_lowercase().contains("bool") || err.contains("'abc'"),
-            "msg debe explicar el tipo o value: {err}"
-        );
-    }
-
-    #[test]
-    fn resolve_param_strict_number_rejects_garbage() {
-        let s = spec("qty", FieldKind::Number, true);
-        let err = resolve_param_value("qty", "abc", Some(&s)).unwrap_err();
-        assert!(err.contains("qty"), "msg debe mencionar el label: {err}");
-    }
-
-    #[test]
-    fn resolve_param_required_empty_rejected() {
-        let s = spec("name", FieldKind::Text, true);
-        let err = resolve_param_value("name", "   ", Some(&s)).unwrap_err();
-        assert!(
-            err.contains("obligatorio"),
-            "msg debe decir obligatorio: {err}"
-        );
-    }
-
-    #[test]
-    fn resolve_param_optional_empty_returns_null() {
-        let s = spec("notes", FieldKind::Text, false);
-        let v = resolve_param_value("notes", "", Some(&s)).unwrap();
-        assert_eq!(v, json!(null));
-    }
-
-    #[test]
-    fn resolve_param_no_spec_falls_back_to_infer() {
-        // Sin FieldSpec (módulo mal-formado): infer_param_value
-        // se usa como red de seguridad.
-        let v = resolve_param_value("foo", "42", None).unwrap();
-        assert_eq!(v, json!(42));
-        let v = resolve_param_value("foo", "true", None).unwrap();
-        assert_eq!(v, json!(true));
-        let v = resolve_param_value("foo", "x", None).unwrap();
-        assert_eq!(v, json!("x"));
-    }
-
-    #[test]
-    fn parse_field_entity_ref_accepts_valid_uuid() {
-        let id = Uuid::new_v4();
-        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
-        assert_eq!(v, json!(id.to_string()));
-    }
-
-    #[test]
-    fn parse_field_entity_ref_trims_whitespace() {
-        // El selector clickable garantiza el value pelado; este check
-        // protege contra paste manual con espacios accidentales.
-        let id = Uuid::new_v4();
-        let padded = format!("  {id}\n");
-        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
-        assert_eq!(v, json!(id.to_string()), "debería trimear y devolver el UUID limpio");
-    }
-
-    #[test]
-    fn parse_field_entity_ref_rejects_non_uuid() {
-        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
-        assert!(err.contains("'abc-123'"), "msg debe mencionar el value: {err}");
-        assert!(
-            err.contains("UUID") || err.contains("uuid"),
-            "msg debe mencionar UUID: {err}"
-        );
-    }
-
-    #[test]
-    fn parse_field_entity_ref_rejects_empty_string() {
-        // Un EntityRef vacío al submit: el form lo manda como ""
-        // si el usuario no clickeó nada. Debería rebotar acá en
-        // lugar de loguear "" como un record id basura.
-        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
-        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
-    }
-
-    #[test]
-    fn resolve_param_strict_entity_ref_propagates_error() {
-        // Sanity: resolve_param_value con kind=EntityRef invoca
-        // parse_field_value y propaga el error de UUID inválido,
-        // con el label del FieldSpec en el mensaje.
-        let s = spec("stock_ref", FieldKind::EntityRef, true);
-        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
-        assert!(err.contains("stock_ref"), "msg debe incluir label: {err}");
-        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
-    }
-
-    #[test]
-    fn human_label_for_record_prefers_name_over_id() {
-        let id = Uuid::new_v4();
-        let with_name = json!({"name": "Acme S.A.", "email": "x@y.z"});
-        let label = human_label_for_record(&with_name, &id);
-        assert!(label.starts_with("Acme S.A."), "got: {label}");
-        assert!(label.contains(&short_uuid(&id)));
-    }
-
-    #[test]
-    fn human_label_falls_back_through_label_title_sku() {
-        let id = Uuid::new_v4();
-        let only_label = json!({"label": "X"});
-        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
-        let only_title = json!({"title": "Y"});
-        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
-        let only_sku = json!({"sku": "Z-001"});
-        assert!(human_label_for_record(&only_sku, &id).starts_with("Z-001 "));
-        let only_sku_id = json!({"sku_id": "W-002"});
-        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W-002 "));
-    }
-
-    #[test]
-    fn human_label_falls_back_to_id_when_no_known_keys() {
-        let id = Uuid::new_v4();
-        let v = json!({"weird_field": "val"});
-        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
-    }
-
-    #[test]
-    fn render_value_handles_null_string_bool() {
-        assert_eq!(render_value(None), "");
-        assert_eq!(render_value(Some(&json!(null))), "");
-        assert_eq!(render_value(Some(&json!("x"))), "x");
-        assert_eq!(render_value(Some(&json!(true))), "✓");
-        assert_eq!(render_value(Some(&json!(false))), "✗");
-        assert_eq!(render_value(Some(&json!(42))), "42");
-    }
-
-    #[test]
-    fn value_to_input_text_inverse_of_parse() {
-        // text → text
-        assert_eq!(value_to_input_text(&json!("hola")), "hola");
-        // bool → "true"/"false" (parse_field_value lo acepta)
-        assert_eq!(value_to_input_text(&json!(true)), "true");
-        assert_eq!(value_to_input_text(&json!(false)), "false");
-        // number → string
-        assert_eq!(value_to_input_text(&json!(42)), "42");
-        assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
-        // null → ""
-        assert_eq!(value_to_input_text(&json!(null)), "");
-    }
-
     #[test]
     fn value_to_input_then_parse_round_trip() {
         // El round-trip es la propiedad fundamental: edit → text →
diff --git a/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml b/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml
new file mode 100644
index 0000000..9437516
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "yahweh-meta-runtime"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, formato) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store."
+
+[dependencies]
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+yahweh-meta-schema = { path = "../meta-schema" }
diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs b/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs
new file mode 100644
index 0000000..bccb5f7
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs
@@ -0,0 +1,134 @@
+//! Cálculo del delta entre el record actual y la propuesta del form.
+//!
+//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
+//! cambian algo: log + apply minimales, no-op edits = 0 entries.
+
+use serde_json::Value;
+
+/// Calcula el delta entre el record actual y los valores propuestos
+/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
+///
+/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
+/// `current=Value::Null` (record no encontrado) hace que todos los
+/// campos del `proposed` sean considerados nuevos. Un campo del
+/// proposed que coincide con el del current se omite. Campos que
+/// están en current pero NO en proposed se preservan tal cual (el
+/// edit no los toca; ver [`compute_clear_fields`] para borrar
+/// explícito desde un input vacío).
+pub fn compute_field_delta(
+    current: &Value,
+    proposed: &serde_json::Map<String, Value>,
+) -> serde_json::Map<String, Value> {
+    proposed
+        .iter()
+        .filter(|(field, value)| current.get(field.as_str()) != Some(*value))
+        .map(|(k, v)| (k.clone(), v.clone()))
+        .collect()
+}
+
+/// Decide cuáles fields del `to_clear` candidate list ameritan
+/// realmente un `FieldOp::Clear`: sólo los que existen en el current
+/// con un valor non-null. Para fields ausentes o ya null, Clear es
+/// no-op semántico (el post-state es el mismo) y dropearlos
+/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
+///
+/// Preserva el orden del input para que el log entry sea estable.
+pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
+    to_clear
+        .iter()
+        .filter(|f| match current.get(f.as_str()) {
+            None | Some(Value::Null) => false,
+            Some(_) => true,
+        })
+        .cloned()
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
+        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
+    }
+
+    #[test]
+    fn delta_empty_when_all_fields_match() {
+        let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
+        let proposed = map(&[
+            ("name", json!("Acme")),
+            ("saldo", json!(100_i64)),
+            ("currency", json!("USD")),
+        ]);
+        assert!(compute_field_delta(&current, &proposed).is_empty());
+    }
+
+    #[test]
+    fn delta_includes_only_changed_field() {
+        let current = json!({"name": "Acme", "saldo": 100_i64});
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
+        let d = compute_field_delta(&current, &proposed);
+        assert_eq!(d.len(), 1);
+        assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
+    }
+
+    #[test]
+    fn delta_treats_missing_record_as_all_new() {
+        let current = Value::Null;
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
+        assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
+    }
+
+    #[test]
+    fn delta_distinguishes_int_from_string_repr() {
+        let current = json!({"qty": 100_i64});
+        let proposed = map(&[("qty", json!(100_i64))]);
+        assert!(compute_field_delta(&current, &proposed).is_empty());
+
+        let current_str = json!({"qty": "100"});
+        let proposed_int = map(&[("qty", json!(100_i64))]);
+        assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
+    }
+
+    #[test]
+    fn delta_skips_fields_absent_from_proposed() {
+        let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
+        let d = compute_field_delta(&current, &proposed);
+        assert_eq!(d.len(), 1);
+        assert!(!d.contains_key("extra"));
+    }
+
+    #[test]
+    fn clear_fields_skips_absent_and_null() {
+        let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
+        let to_clear = vec![
+            "name".into(),
+            "notes".into(),
+            "tag".into(),
+            "missing".into(),
+        ];
+        assert_eq!(
+            compute_clear_fields(&current, &to_clear),
+            vec!["name".to_string(), "notes".to_string()]
+        );
+    }
+
+    #[test]
+    fn clear_fields_preserves_input_order() {
+        let current = json!({"a": 1, "b": 2, "c": 3});
+        let to_clear = vec!["c".into(), "a".into(), "b".into()];
+        assert_eq!(
+            compute_clear_fields(&current, &to_clear),
+            vec!["c", "a", "b"]
+        );
+    }
+
+    #[test]
+    fn clear_fields_empty_when_current_is_null() {
+        let current = Value::Null;
+        let to_clear = vec!["name".into()];
+        assert!(compute_clear_fields(&current, &to_clear).is_empty());
+    }
+}
diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/format.rs b/crates/modules/ui_engine/libs/meta-runtime/src/format.rs
new file mode 100644
index 0000000..e827b9a
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/src/format.rs
@@ -0,0 +1,116 @@
+//! Helpers de presentación humana para records y values.
+//!
+//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
+//! en `div().child(...)` o equivalente.
+
+use serde_json::Value;
+use uuid::Uuid;
+
+/// Etiqueta humana para representar un record en el selector de
+/// EntityRef. Heurística: prefiere campos comunes en este orden:
+/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
+pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
+    for key in ["name", "label", "title", "sku", "sku_id"] {
+        if let Some(v) = value.get(key).and_then(Value::as_str) {
+            if !v.is_empty() {
+                return format!("{} ({})", v, short_uuid(id));
+            }
+        }
+    }
+    short_uuid(id)
+}
+
+/// Render legible de un `Value` arbitrario para mostrar en una celda
+/// de lista. Strings van pelados; bools como ✓/✗; el resto via
+/// `Display`.
+pub fn render_value(v: Option<&Value>) -> String {
+    match v {
+        None | Some(Value::Null) => String::new(),
+        Some(Value::String(s)) => s.clone(),
+        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
+        Some(Value::Number(n)) => n.to_string(),
+        Some(other) => other.to_string(),
+    }
+}
+
+/// 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.
+pub fn value_to_input_text(v: &Value) -> String {
+    match v {
+        Value::Null => String::new(),
+        Value::String(s) => s.clone(),
+        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
+        Value::Number(n) => n.to_string(),
+        other => other.to_string(),
+    }
+}
+
+/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
+/// donde el UUID full es ruido visual.
+pub fn short_uuid(id: &Uuid) -> String {
+    id.to_string().chars().take(8).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    #[test]
+    fn human_label_prefers_name_over_id() {
+        let id = Uuid::new_v4();
+        let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
+        let label = human_label_for_record(&v, &id);
+        assert!(label.starts_with("Acme S.A."));
+        assert!(label.contains(&short_uuid(&id)));
+    }
+
+    #[test]
+    fn human_label_falls_back_through_label_title_sku() {
+        let id = Uuid::new_v4();
+        let only_label = json!({"label": "X"});
+        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
+        let only_title = json!({"title": "Y"});
+        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
+        let only_sku = json!({"sku": "Z"});
+        assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
+        let only_sku_id = json!({"sku_id": "W"});
+        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
+    }
+
+    #[test]
+    fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
+        let id = Uuid::new_v4();
+        let v = json!({"random": "field"});
+        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
+    }
+
+    #[test]
+    fn render_value_handles_basic_kinds() {
+        assert_eq!(render_value(None), "");
+        assert_eq!(render_value(Some(&Value::Null)), "");
+        assert_eq!(render_value(Some(&json!("hola"))), "hola");
+        assert_eq!(render_value(Some(&json!(true))), "✓");
+        assert_eq!(render_value(Some(&json!(false))), "✗");
+        assert_eq!(render_value(Some(&json!(42))), "42");
+    }
+
+    #[test]
+    fn value_to_input_text_round_trip_with_strings_and_numbers() {
+        assert_eq!(value_to_input_text(&Value::Null), "");
+        assert_eq!(value_to_input_text(&json!("x")), "x");
+        assert_eq!(value_to_input_text(&json!(true)), "true");
+        assert_eq!(value_to_input_text(&json!(false)), "false");
+        assert_eq!(value_to_input_text(&json!(42)), "42");
+    }
+
+    #[test]
+    fn short_uuid_returns_first_8_chars() {
+        let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
+        // Si el parse falla, usamos uno fresco — el invariant es la
+        // longitud, no el contenido.
+        let id = id.unwrap_or_else(Uuid::new_v4);
+        assert_eq!(short_uuid(&id).len(), 8);
+    }
+}
diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs
new file mode 100644
index 0000000..788f854
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs
@@ -0,0 +1,33 @@
+//! `yahweh-meta-runtime` — helpers puros para runtimes metainterfaz.
+//!
+//! Consume [`yahweh_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
+//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
+//! widget renderer o backend ejecutor necesita:
+//!
+//! - **Parse**: convertir el texto de un input a `serde_json::Value`
+//!   tipado según el `FieldKind` del spec.
+//! - **Delta**: calcular qué cambió entre el estado actual y la
+//!   propuesta del form (Set + Clear).
+//! - **Validation**: verificar que cada EntityRef apunte a un record
+//!   que existe (toma cierre `load`, no trait).
+//! - **Format**: presentación humana de records (label heurístico,
+//!   render de values, UUID corto, round-trip a input text).
+//!
+//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
+//! implementación de store/log puede consumirlos.
+//!
+//! El widget render (form/list/modal) vive en otro crate yahweh
+//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
+//! conexión a su event-log/executor y compone ambos.
+
+#![forbid(unsafe_code)]
+
+pub mod delta;
+pub mod format;
+pub mod parse;
+pub mod refs;
+
+pub use delta::{compute_clear_fields, compute_field_delta};
+pub use format::{human_label_for_record, render_value, short_uuid, value_to_input_text};
+pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
+pub use refs::validate_entity_refs;
diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs b/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs
new file mode 100644
index 0000000..3f0274f
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs
@@ -0,0 +1,231 @@
+//! Parseo de inputs del form a `serde_json::Value` tipado.
+
+use serde_json::{json, Value};
+use uuid::Uuid;
+
+use yahweh_meta_schema::{FieldKind, FieldSpec};
+
+/// Convierte el texto raw de un input al `Value` tipado según el
+/// `kind` del spec.
+///
+/// - `Text` / `Multiline` / `Date` → string passthrough.
+/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
+///   parseable. Falla con mensaje claro si no parsea.
+/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
+/// - `Number` → i64 si parsea, sino f64.
+pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
+    match kind {
+        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
+        // EntityRef se almacena como string del UUID seleccionado.
+        // El selector clickable garantiza UUIDs válidos en happy
+        // path; este check protege paste manual o garbage tipeado.
+        FieldKind::EntityRef => {
+            let trimmed = raw.trim();
+            Uuid::parse_str(trimmed).map_err(|_| {
+                format!("'{raw}' no es UUID válido (usá el selector de records)")
+            })?;
+            Ok(json!(trimmed))
+        }
+        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
+            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
+            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
+            other => Err(format!("'{other}' no es booleano")),
+        },
+        FieldKind::Number => {
+            if let Ok(i) = raw.parse::<i64>() {
+                Ok(json!(i))
+            } else if let Ok(f) = raw.parse::<f64>() {
+                Ok(json!(f))
+            } else {
+                Err(format!("'{raw}' no es número"))
+            }
+        }
+    }
+}
+
+/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
+/// del form. **Strict path**: si hay spec, valida `required` y parsea
+/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
+/// llegar al morphism). **Fallback path**: si no hay spec (param
+/// declarado en `Action::Morphism.params` que no aparece en
+/// `form.fields`), usa la heurística [`infer_param_value`] para no
+/// quedar atado a un schema mal-formado.
+///
+/// Errores tienen el label legible del spec, así el toast de la UI
+/// es interpretable.
+pub fn resolve_param_value(
+    field_name: &str,
+    raw: &str,
+    spec: Option<&FieldSpec>,
+) -> Result<Value, String> {
+    let Some(s) = spec else {
+        return Ok(infer_param_value(raw));
+    };
+
+    let label = if s.label.is_empty() { field_name } else { &s.label };
+
+    if s.required && raw.trim().is_empty() {
+        return Err(format!("param '{label}' es obligatorio y está vacío"));
+    }
+    if raw.is_empty() && !s.required {
+        return Ok(Value::Null);
+    }
+    parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
+}
+
+/// Inferencia de tipo para values pasados como `params` a un
+/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
+/// param declarado en `Action::Morphism.params` no aparece en los
+/// `form.fields` (módulo mal-formado).
+///
+/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
+/// resto → string.
+pub 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)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use yahweh_meta_schema::FieldSpec;
+
+    fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
+        FieldSpec {
+            name: name.into(),
+            label: name.into(),
+            kind,
+            default: None,
+            required,
+            help: None,
+            ref_entity: None,
+        }
+    }
+
+    #[test]
+    fn infer_handles_basic_types() {
+        assert_eq!(infer_param_value(""), Value::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 parse_text_passthrough() {
+        let v = parse_field_value(FieldKind::Text, "hola").unwrap();
+        assert_eq!(v, json!("hola"));
+    }
+
+    #[test]
+    fn parse_number_i64_or_f64() {
+        assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
+        assert_eq!(
+            parse_field_value(FieldKind::Number, "3.14").unwrap(),
+            json!(3.14)
+        );
+        assert!(parse_field_value(FieldKind::Number, "abc").is_err());
+    }
+
+    #[test]
+    fn parse_boolean_recognizes_variants() {
+        for s in ["true", "yes", "1", "on", "y"] {
+            assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
+        }
+        for s in ["false", "no", "0", "off", "n", ""] {
+            assert_eq!(
+                parse_field_value(FieldKind::Boolean, s).unwrap(),
+                json!(false)
+            );
+        }
+        assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
+    }
+
+    #[test]
+    fn parse_entity_ref_accepts_valid_uuid() {
+        let id = Uuid::new_v4();
+        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
+        assert_eq!(v, json!(id.to_string()));
+    }
+
+    #[test]
+    fn parse_entity_ref_trims_whitespace() {
+        let id = Uuid::new_v4();
+        let padded = format!("  {id}\n");
+        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
+        assert_eq!(v, json!(id.to_string()));
+    }
+
+    #[test]
+    fn parse_entity_ref_rejects_non_uuid() {
+        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
+        assert!(err.contains("'abc-123'"));
+        assert!(err.contains("UUID") || err.contains("uuid"));
+    }
+
+    #[test]
+    fn parse_entity_ref_rejects_empty_string() {
+        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
+        assert!(err.contains("UUID"));
+    }
+
+    #[test]
+    fn resolve_param_strict_number_parses_i64() {
+        let s = spec("qty", FieldKind::Number, true);
+        let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
+        assert_eq!(v, json!(42));
+    }
+
+    #[test]
+    fn resolve_param_strict_boolean_rejects_non_boolean() {
+        let s = spec("active", FieldKind::Boolean, true);
+        let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
+        assert!(err.contains("active"));
+    }
+
+    #[test]
+    fn resolve_param_required_empty_rejected() {
+        let s = spec("name", FieldKind::Text, true);
+        let err = resolve_param_value("name", "   ", Some(&s)).unwrap_err();
+        assert!(err.contains("obligatorio"));
+    }
+
+    #[test]
+    fn resolve_param_optional_empty_returns_null() {
+        let s = spec("notes", FieldKind::Text, false);
+        let v = resolve_param_value("notes", "", Some(&s)).unwrap();
+        assert_eq!(v, Value::Null);
+    }
+
+    #[test]
+    fn resolve_param_no_spec_falls_back_to_infer() {
+        let v = resolve_param_value("foo", "42", None).unwrap();
+        assert_eq!(v, json!(42));
+        let v = resolve_param_value("foo", "true", None).unwrap();
+        assert_eq!(v, json!(true));
+        let v = resolve_param_value("foo", "x", None).unwrap();
+        assert_eq!(v, json!("x"));
+    }
+
+    #[test]
+    fn resolve_param_strict_entity_ref_propagates_error() {
+        let s = spec("stock_ref", FieldKind::EntityRef, true);
+        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
+        assert!(err.contains("stock_ref"));
+        assert!(err.contains("UUID"));
+    }
+}
diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs b/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs
new file mode 100644
index 0000000..47d679f
--- /dev/null
+++ b/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs
@@ -0,0 +1,108 @@
+//! Validación cross-field de EntityRefs contra el store actual.
+//!
+//! Decoupling: en vez de un `trait Store` que ate este crate a un
+//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
+//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
+//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
+//! SurrealStore, mock, ...).
+
+use serde_json::Value;
+use uuid::Uuid;
+
+use crate::format::short_uuid;
+
+/// Valida que cada UUID en `refs` apunte a un record que realmente
+/// existe en el store bajo la entity esperada. Devuelve el primer
+/// error encontrado (fail-fast).
+///
+/// `refs` es una lista de `(label, target_entity, uuid)`. El label
+/// va al error message, así que conviene que sea legible (ej:
+/// `FieldSpec.label` en lugar de `FieldSpec.name`).
+///
+/// `load` es el cierre que el caller usa para mirar el store —
+/// típicamente `|e, id| store.load(e, id)`.
+pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
+where
+    F: Fn(&str, Uuid) -> Option<Value>,
+{
+    for (label, target, id) in refs {
+        if load(target, *id).is_none() {
+            return Err(format!(
+                "campo '{label}': record {} de '{target}' no existe en el store",
+                short_uuid(id)
+            ));
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+    use std::collections::HashMap;
+
+    /// "Mock store" minimalista para tests: HashMap por (entity, uuid).
+    fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
+        move |e, id| records.get(&(e.to_string(), id)).cloned()
+    }
+
+    #[test]
+    fn passes_when_all_records_exist() {
+        let stock = Uuid::new_v4();
+        let caja = Uuid::new_v4();
+        let mut records = HashMap::new();
+        records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
+        records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
+        let load = mk_load(records);
+
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock),
+            ("Caja".into(), "Caja".into(), caja),
+        ];
+        assert!(validate_entity_refs(load, &refs).is_ok());
+    }
+
+    #[test]
+    fn fails_on_first_missing() {
+        let stock = Uuid::new_v4();
+        let mut records = HashMap::new();
+        records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
+        let load = mk_load(records);
+
+        let missing_caja = Uuid::new_v4();
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock),
+            ("Caja".into(), "Caja".into(), missing_caja),
+        ];
+        let err = validate_entity_refs(load, &refs).unwrap_err();
+        assert!(err.contains("Caja"));
+        assert!(err.contains(&short_uuid(&missing_caja)));
+    }
+
+    #[test]
+    fn uses_label_not_entity_in_msg() {
+        let load = |_: &str, _: Uuid| -> Option<Value> { None };
+        let id = Uuid::new_v4();
+        let refs = vec![("Stock origen".into(), "Stock".into(), id)];
+        let err = validate_entity_refs(load, &refs).unwrap_err();
+        assert!(err.contains("Stock origen"));
+    }
+
+    #[test]
+    fn empty_list_is_ok() {
+        let load = |_: &str, _: Uuid| -> Option<Value> { None };
+        assert!(validate_entity_refs(load, &[]).is_ok());
+    }
+
+    #[test]
+    fn distinguishes_target_from_other_entities() {
+        let id = Uuid::new_v4();
+        let mut records = HashMap::new();
+        // Mismo UUID bajo Customer pero NO bajo Stock.
+        records.insert(("Customer".into(), id), json!({"name": "Acme"}));
+        let load = mk_load(records);
+        let refs = vec![("Stock".into(), "Stock".into(), id)];
+        assert!(validate_entity_refs(load, &refs).is_err());
+    }
+}