Files
sergio c56ef25546 feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)
Validación inline: al fallar un submit por campos required vacíos, el
form los marca (label destructivo + mensaje debajo), no sólo un toast.
MetaApp.form_errors + validate_required_fields. Secciones de formulario:
FieldSpec.section agrupa campos bajo encabezados; abrir_form del CRM las
usa. Campos condicionales y pulido puramente visual: scope-out conciente.

El plan docs/nakui-erp-masterplan.md queda completo (7/7 fases). Tests
verdes (meta-schema 16, meta-runtime 70, meta-form 8, nakui-ui 14);
clippy limpio en las libs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:43:44 +00:00

66 KiB

Changelog — nakui

ERP categórico.

feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)

Última fase: el plan docs/nakui-erp-masterplan.md queda completo (7/7). Pulido de formularios — validación inline (campos obligatorios marcados al fallar el submit) y secciones de formulario; el abrir_form del CRM agrupa sus campos en «Oportunidad» e «Importe y fecha». Ver el changelog de nahual (FieldSpec.section, form_errors).

Nakui es ahora un ERP dirigido por datos completo: tablero → listas (orden/filtro/paginación/export CSV) → fichas con relacionados → formularios con captura sin fricción y validación inline.

feat(nakui): Fase 6 del ERP — export CSV de listas

Sexta fase del plan maestro. Toda lista del ERP (clientes, oportunidades, interacciones y los demás módulos) gana un botón «⬇ CSV» que exporta sus filas a un archivo. Ver el changelog de nahual (to_csv / meta-form).

feat(nakui): Fase 5 del ERP — tablero de KPIs

Quinta fase del plan maestro. El módulo CRM gana una vista «Panorama» (primera del menú): tarjetas de KPI — total de clientes y oportunidades, monto en pipeline, oportunidades ganadas y monto ganado, más breakdowns de oportunidades por etapa e interacciones por canal.

Tipos nuevos en la metainterfaz: ver el changelog de nahual (View::Dashboard / Metric / compute_metric).

feat(nakui): Fase 4 del ERP — listas profesionales (orden/búsqueda/página)

Las vistas de lista de meta-form ganan orden por columna, búsqueda en vivo y paginación. Ver el changelog de nahual.

feat(nakui): Fase 3 del ERP — ficha de detalle

Tercera fase del plan maestro. El módulo CRM:

  • Las listas de Clientes y Oportunidades ganan un botón 👁 por fila que abre la ficha del record (row_detail).
  • cliente_detail muestra los campos del cliente + sus oportunidades e interacciones (back-references). oportunidad_detail muestra los campos de la oportunidad, con el cliente resuelto a su nombre.
  • Navegación lista → ficha → volver.

Tipos nuevos en la metainterfaz: ver el changelog de nahual (View::Detail / RelatedList / ListView.row_detail).

feat(nakui): Fase 2 del ERP — relaciones legibles + formato

Segunda fase del plan maestro. El módulo CRM:

  • Las columnas cliente_id de las listas de Oportunidades e Interacciones muestran el nombre del cliente, no su UUID (ref_entity en la columna).
  • La columna monto se formatea como moneda ($12,000).
  • En los formularios, el campo de cliente/oportunidad muestra el record elegido por su nombre.

Tipos nuevos en la metainterfaz: ver el changelog de nahual (Column.ref_entity / Column.format).

feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)

Plan maestro del subproyecto en docs/nakui-erp-masterplan.md: 7 fases ordenadas para llevar nakui de "listas y formularios que funcionan" a "ERP profesional terminado". Fase 1 implementada:

  • El módulo CRM usa select para etapa y canal (desplegable de opciones) y auto_id para los ids de idempotencia — ningún formulario pide un UUID a mano.
  • NakuiBackend::seed inyecta el id de la entity = clave del store, así data.id y la clave coinciden (los schemas Nickel declaran id | String); el formulario de cliente ya no necesita campo id.
  • Tipos de campo nuevos en la metainterfaz: ver el changelog de nahual (FieldKind::Select / AutoId).

feat(nakui-ui): CRM como ERP — listas y formularios

UiModule examples/nakui-modules/crm/module.json: hace que el módulo crm se vea como un ERP de verdad en nakui-ui (sidebar + listas + formularios), no como el timeline del event log de nakui-explorer.

  • 3 entities, 7 vistas: lista + formulario de Clientes, Oportunidades e Interacciones, más los formularios de morfismo «Abrir», «Mover» y «Registrar».
  • nakui_module_dir engancha el módulo-kernel crm: el form de cliente siembra (seed_entity); «Abrir/Mover/Registrar» disparan los morfismos reales con la validación del kernel (etapas, transiciones, canal).
  • 2 tests en nakui-ui verifican que el UiModule parsea, valida y carga por el camino real (brahman_cards::load_cards_from_dir).

Correr: NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui.

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

Módulo CRM funcional, declarativo como inventory/sales/treasury (crates/modules/nakui/modules/crm/): schema.ncl + nsmc.json + morfismos Rhai. Tres entities — Cliente, Oportunidad, Interaccion — y tres morfismos:

  • abrir_oportunidad(cliente) — crea una Oportunidad en etapa prospecto.

  • mover_oportunidad(oportunidad) — avanza el pipeline (prospecto→calificado→propuesta→negociacion→ganada/perdida). Valida: etapa destino conocida, no mover una oportunidad cerrada, no retroceder.

  • registrar_interaccion(cliente) — crea una Interaccion (llamada/email/reunión).

  • core/src/bin/crm_demo.rs — demo realista (3 clientes, 18 eventos). A diferencia de los otros demos no borra el event log: lo deja en disco e imprime el comando para abrirlo con nakui-explorer — así el explorador, que sólo mostraba un log vacío, muestra un CRM con cuerpo.

  • core/tests/crm.rs — 8 tests de integración (pipeline completo + rechazos: retroceso, oportunidad cerrada, etapa/canal/monto inválidos).

Nota: el engine Rhai de nakui-core es Engine::new_raw() con paquetes selectos — Array::index_of no resuelve; los morfismos usan sólo if/==/throw. Iter 17. Regresión surfaceada por el workspace test verify_log_rejects_seed_after_schema_kcl_changes (rebautizado a verify_log_rejects_seed_after_schema_changes).

Bug: compute_schema_bundle_hash operaba sobre los bytes del bundle compilado, que build_schema_bundle arma como (import "/abs/path/schema.ncl") & .... Los bytes del bundle nunca cambian cuando se edita el archivo apuntado — sólo cambian si se agregan/quitan schemas o se mueve el módulo de path. Resultado: el hash quedaba pegado y los seeds firmados bajo schema vN se aceptaban silenciosamente como válidos bajo schema vN+1, aunque las invariantes hubieran cambiado.

Fix: nueva fn read_schema_files_concat(module_dir, schemas) que lee los bytes de cada schema declarado y los concatena con framing \0NCL:<name>\0 (separador no ambiguo + nombre relativo, no absoluto, para que el hash sea estable entre máquinas). Esos bytes alimentan compute_schema_bundle_hash y compute_morphism_schema_hash en lugar de los bytes del bundle. El bundle compilado sigue siendo imports-style (necesario para que Nickel resuelva paths relativos); sólo la fuente del hash cambió.

Impacto en logs existentes: como cualquier cambio al insumo del hash, los seeds y morphisms versados bajo el bundle hash anterior fallarán SchemaMismatch al verificarse contra un binario nuevo. No hay migración — esto es exactamente el comportamiento que el hash busca: re-seed.

Tests: 10/10 en schema_versioning (era 9/10 con 1 FAILED).

refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process

Cierra el ciclo: el motor de validación de entities deja de shellear el binario externo kcl y pasa a evaluar Nickel contracts in-process via la dep nickel-lang (la misma que ya usa brahman-cards para sus templates). Los 3 schemas de los módulos sales/inventory/treasury migran de .k a .ncl. Además se borran los 2 archivos .k doc-only del repo (ente-card/schema/card.k, ente-brain/schema/rule.k — ambos estaban marcados "REFERENCE ONLY. NOT LOADED").

Cambios en nakui-core:

  • Nueva dep: nickel-lang = "2.0.0" (interfaz estable).
  • Borrado kcl_wrapper.rs (43 líneas) — shellear el binario desaparece.
  • Nuevo nickel_validator.rs:
    • pub fn vet(schema_path, state, schema_name) -> Result<(), NickelError> evalúa let bundle = (import "<schema>") in (std.deserialize 'Json m%%"<json>"%%) | bundle.<entity>.
    • El state JSON va dentro de un raw string Nickel (m%%"..."%%) y se deserialize via std.deserialize 'Json. No embebemos el state como record literal Nickel directo porque la sintaxis JSON usa : (Nickel records usan =).
    • 5 tests propios cubriendo happy path + 4 fallure modes (field missing, predicate fails, cross-field invariant fails, optional field present/absent).
  • executor.rs:
    • kcl_wrapper::vetnickel_validator::vet.
    • KclErrorNickelError.
    • ExecError::KclPre/KclPost/KclPostCreateSchemaPre/Post/PostCreate (más neutro, ya no menciona KCL).
    • kcl_check (privado) → validate_entity.
    • build_schema_bundle ahora emite un archivo Nickel con (import "X") & (import "Y") & ... en lugar de concatenar bytes (cada .ncl es una expresión record completa, no juntable como texto plano).
  • manifest.rs:
    • effective_schemas default "schema.k""schema.ncl".
    • extract_schema_names reescrito: ahora detecta keys CapitalCase con 2 spaces de indent (convención de los schema.ncl), no más patrón schema X: de KCL.
    • Tests del extractor actualizados (1 test reemplazado por 2: _handles_nickel_record_top_level + _skips_let_bindings_and_lowercase).

Cambios en schemas de módulos:

  • sales/schema.ncl: contracts Nickel para Venta. Usa std.contract.Sequence [record_contract, from_predicate] para combinar shape + invariante cross-field (total == cantidad * precio_unitario). El patrón directo record | from_predicate rebota con "missing definition" porque el predicate evalúa el contract antes de que el value lo populate; documentado en el comment.
  • inventory/schema.ncl: Stock, MovimientoStock, TransferenciaStock (esta última con cross-field source != dest via Sequence).
  • treasury/schema.ncl: Caja, Movimiento, Transferencia (con cross-field via Sequence).
  • Helpers locales en cada archivo: positive_int, non_negative_int, currency_iso, etc. via std.contract.from_predicate.
  • Los 3 schema.k viejos borrados.
  • sales/nsmc.json actualizado: paths schema.kschema.ncl.

Cambios en tests:

  • sales.rs, inventory.rs: KclPostSchemaPost.
  • kernel_guards.rs: KclPostCreateSchemaPostCreate, path del schema directo treasury/schema.ktreasury/schema.ncl.
  • graph.rs, manifest_validation.rs: tests que escriben schema.k inline cambian a schema.ncl con sintaxis Nickel.
  • schema_versioning.rs: refs schema.kschema.ncl.

Cambios documentales:

  • Borrado crates/core/ente-card/schema/card.k (1 archivo, REFERENCE ONLY documentado en su header).
  • Borrado crates/core/ente-brain/schema/rule.k (REFERENCE ONLY documentado en su header).

Tests:

  • nakui-core: 84 tests verdes (41 unit + 43 integration en graph/event_log/manifest_validation/schema_versioning/ inventory/sales/kernel_guards). Suite full pasa.
  • nakui-ui, brahman-cards, yahweh-*: sin cambios, todos verdes.
  • Total cubriendo el área: 174 tests.

Beneficios:

  • Sin dep externa: el binario kcl ya no es requisito de runtime ni de tests. Build limpio en CI sin instalar KCL.
  • Errores en línea: Nickel reporta contract violations con caret pointing al field exacto del schema y el value que falló. KCL daba mensajes textuales menos navegables.
  • Mismo motor que el brazo de cards: una sola dependencia Nickel para todo el repo (validación + templates de cards).
  • Sin tempfile JSON intermedio: el state se evalúa directamente en memoria; no hay std::fs::write por cada validate.

Limitaciones / decisiones:

  • El comentario "REFERENCE ONLY" de los .k borrados ya estaba marcado en sus headers; eran sólo notas de diseño para humanos. La autoridad real (Rust validate methods) sigue intacta.
  • La sintaxis Nickel record_contract | from_predicate no funciona — hay que envolver en std.contract.Sequence [record, from_predicate]. Documentado en cada schema y en el doc del validator.

Pendientes restantes: ninguno del refactor original. Los yahweh + KCL + card.k cierran. Próximos pendientes salen de nuevo trabajo (no del plan que arrastrábamos).

feat(nakui-ui): migrar consumer al brazo unificado brahman_cards::load_cards_from_dir

Primera consumer migration del brazo. nakui-ui ya no llama a nakui_ui_schema::load_modules_from_dir directamente — pasa por brahman_cards::load_cards_from_dir y extrae el variant UiModule del CardBody de cada Card. Beneficios concretos:

  • Soporta .ncl además de .json: el usuario puede dropear un card.ncl (con templates Nickel + merge) en cualquier subdir y el runtime lo levanta automáticamente. El layout legacy examples/nakui-modules/<id>/module.json sigue funcionando vía los filenames default [card.ncl, card.json, module.ncl, module.json].
  • Cards de otros body kinds (Ente/Monad) se skipean limpio: si el dir contiene Cards no-UiModule, se reportan en un toast informativo en lugar de fallar la carga.

Cambios en brahman-cards:

  • Nuevo load_cards_from_dir(dir) + variante con readers/filenames custom. Walkea subdirs (orden lexicográfico), busca el primero de DEFAULT_CARD_FILENAMES, dispatcha al reader. Subdirs sin ningún filename matching se skipean silenciosamente (permite assets/fixtures sueltos al lado de los cards). Errores per-file se propagan loud (sin ocultar corrupción).
  • pub const DEFAULT_CARD_FILENAMES: lista canónica probada en orden. card.ncl tiene prioridad sobre card.json y sobre los legacy module.*.
  • 4 tests nuevos del helper: walk + skip de subdirs sin card, prioridad ncl > json, propagación loud de errores per-file, custom filenames.

Cambios en nakui-ui:

  • Nueva dep brahman-cards en Cargo.toml.
  • Nuevo helper load_ui_modules(dir) -> (Vec<Module>, Vec<String>) que envuelve brahman_cards::load_cards_from_dir, filtra a UiModule body, valida cada Module con su validate(), ordena por id, y detecta duplicados. El callsite en MetaUi::new pasa a usarlo y al ver Cards skipped emite un toast informativo.
  • 3 tests nuevos:
    • load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others — verifica que un dir con UiModule + Ente carga el primero y reporta el segundo en skipped.
    • load_ui_modules_via_brahman_cards_rejects_invalid_moduleModule::validate() se sigue aplicando (menu apuntando a view inexistente rebota).
    • load_ui_modules_detects_duplicate_id — dos UiModule con mismo id rebotan con mensaje claro.

Tests totales:

  • brahman-cards: 22 → 26 (+4 helper directorio).
  • nakui-ui: 45 → 48 (+3 e2e migración).
  • Workspace build verde.

Lo que NO cambió:

  • nakui_ui_schema::load_modules_from_dir se mantiene intacto (sus propios tests lo siguen usando, y otros consumers futuros podrían preferir su error-typing más específico). La migración es opt-in: nakui-ui usa el brazo, ui-schema sigue siendo una API válida.
  • Layout actual de examples/nakui-modules/<id>/module.json no requiere cambio. Un usuario puede convertir cualquier módulo a card.ncl sin tocar el dir layout.

Pendientes para próximos commits (orden):

  1. Yahweh refactor: lift del MetaUi runtime a crates/modules/ui_engine/ para reuso. El brazo + canónico ya estables, ahora puede extraerse el meta-form widget genérico.
  2. KCL → Nickel: kcl_wrapper reemplazado por Nickel contracts; los 3 schemas .k de nakui modules pasan a .ncl.
  3. card.k eliminado (es REFERENCE ONLY documentado).

feat(nakui-ui): validación cross-field del EntityRef (existence en store)

Cierra otro pendiente. Hasta ahora parse_field_value(EntityRef, raw) sólo validaba forma (UUID parseable + trim de whitespace) — un UUID válido pero inexistente en el store pasaba silenciosamente al log/store, dejando dangling references. Ahora validamos también existencia contra la entity declarada en FieldSpec.ref_entity.

Cambios:

  • Nuevo helper validate_entity_refs<S: Store>(store, refs):
    • refs: &[(label, target_entity, uuid)].
    • Loop fail-fast: primer record ausente → error con label legible + UUID corto + target entity en el msg "campo 'Stock': record abc12345 de 'Stock' no existe en el store".
    • Pure (toma &S: Store), totalmente testable sin GPUI.
  • Wireup en commit_seed:
    • Durante el parse loop, cuando un field es EntityRef + tiene ref_entity declarado + value parseado a UUID, lo encolamos en entity_refs: Vec<(String, String, Uuid)>.
    • Después del parse loop (antes del seed/edit branch), si entity_refs no está vacío, una sola toma del store lock para validar todos via el helper.
    • Falla early: ningún log entry, ningún apply.
  • Cobertura:
    • SEED path: alta nueva con EntityRef → validamos antes de Seed { data }.
    • EDIT path: edit con EntityRef → validamos antes de calcular delta. Una optional empty (que iría a clear) no cuenta como EntityRef (raw vacío skipea el push).
    • Morphism inputs: NO se duplica acá. Executor::compute ya valida cada input via store.load(...).ok_or(EntityMissing) antes de correr el script Rhai. Documentado en el doc del helper.

5 tests nuevos:

  • validate_entity_refs_passes_when_all_records_exist — happy path.
  • validate_entity_refs_fails_on_first_missing — fail-fast con msg que incluye entity + UUID corto.
  • validate_entity_refs_uses_label_not_entity_in_msg — el label legible (ej: "Stock origen") aparece en el error, no la entity desnuda.
  • validate_entity_refs_empty_list_is_ok — lista vacía es Ok.
  • validate_entity_refs_distinguishes_target_from_other_entities — un UUID que existe bajo Customer pero NO Stock falla la validación contra Stock.

45 tests verdes en nakui-ui (+5). Workspace build verde.

Comportamiento esperado:

  • Selector clickable es happy path: el dropdown sólo lista records existentes, así que clickearlo nunca debería disparar el error. Sólo dispara con paste manual de UUID que no existe o records borrados después de la selección (timing race).
  • Optional empty no se valida: si el field es EntityRef optional y el form lo deja vacío, lo manejamos como "no value" (skipea el push a entity_refs); la lógica de Clear se encarga del resto.
  • Lock contention: una sola toma del store lock por commit_seed, no una por field. La validación es O(refs) reads.

Pendientes restantes:

  • Validación KCL del record post-edit antes de emitir Set/Clear (hoy ui.edit_record no pasa por Executor::compute).
  • EntityRef cross-module (referenciar records de OTRO módulo por nombre, no sólo por entity local).

feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío

Cierra el último pendiente de UX del round. El edit no podía "borrar" un value vaciando el input — empty optional fields hacían continue en commit_seed, así que el current value quedaba intacto. Para honrar el intent del usuario ("este field ya no aplica") necesitábamos un FieldOp explícito que remueva la key del map.

Cambios en nakui-core (la variante es semántica del kernel, no específica de la UI):

  • delta::FieldOp::Clear { path } — nueva variante. Distinta de Set { value: Null }: Clear borra la clave; Set Null deja la clave con valor literal null. Importa para downstream que diferencia "ausente" vs "presente como null" (ej: serde con skip_serializing_if = "Option::is_none").
  • capability_token — Clear devuelve entity.field, mismo shape que Set. Una capability writes: ["Customer.notes"] autoriza tanto Set como Clear sobre ese field.
  • simulate_on — Clear remueve la key del Object si el state es Some(Object). Skip silente si el state es None (deleted) o no-objeto.
  • MemoryStore::apply_dry_run — Set y Clear comparten pre-condición (record padre existe + es objeto). Pattern combinado con |.
  • MemoryStore::apply — Clear hace map.remove(field). Field ausente = no-op silencioso (post-state idéntico).
  • SurrealStore::apply_dry_run — Set/Clear combinados.
  • SurrealStore::apply — Clear emite UPDATE type::thing UNSET <field>. El field name viene de un FieldSpec validado upstream; SurrealQL no soporta binding de identifiers, así que va inline (con la advertencia documentada en el comment).
  • Executor capability check — Set/Clear comparten match (mismo token shape, misma resolución a binding role).
  • Conservation rules (en check_conservation) NO consideran Clear — sólo Set. Documentado: morphism authors que querían clear de un field con conservation tienen que ser cuidadosos; KCL post-checks pueden capturar violations.

Cambios en nakui-ui:

  • commit_seed loop acumula to_clear: Vec<String> con los nombres de fields optional empty (en lugar de hacer continue silencioso).
  • EDIT branch:
    • Computa set_delta (igual que antes) + clear_fields via nuevo helper compute_clear_fields(current, to_clear).
    • Helper filtra a sólo los fields que actualmente tienen valor non-null — Clear de un field ausente o ya null no se emite (sería no-op semántico). Preserva el orden del input para estabilidad del log entry.
    • Construye ops combinando Set + Clear.
    • NoChange ahora requiere AMBOS vacíos (set_delta y clear_fields).
    • params del log entry incluye cleared: ["field1", ...] sólo si non-empty (preserva la shape fields: para edits sin clears).
    • CommitOutcome::Updated.changed = sets + clears para que el toast "actualizado X (N campo(s))" siga siendo preciso.

Tests nuevos:

  • delta.rs: simulate_clear_removes_field, simulate_clear_then_set_same_field_keeps_set, clear_capability_token_matches_set_shape.
  • store.rs: apply_clear_removes_field_key, apply_clear_on_absent_field_is_noop, dry_run_rejects_clear_on_missing_record, dry_run_rejects_clear_on_non_object.
  • nakui-ui main.rs: clear_fields_skips_absent_and_null, clear_fields_preserves_input_order, clear_fields_empty_when_current_is_null.

34 tests verdes en nakui-core (+7), 40 en nakui-ui (+3). Workspace build verde. E2E del morphism real morphism_pipeline_executes_real_sales_vender intacto — vender no usa Clear.

Implicaciones:

  • El log puede crecer con entries ui.edit_record que sólo tienen cleared: [...] sin fields. Esperado y esperable.
  • Replay: las entries con Clear se aplican en orden via store.apply(&ops). La semantic es deterministic.
  • Si un módulo tiene KCL invariants sobre la presencia de un field, el usuario podría romper el record vaciando ese field via UI. Hoy esto NO se chequea — ui.edit_record es un morphism manual que no pasa por Executor::compute. Si esto es un problema, el camino futuro es validar contra el KCL del entity al submit (otro pendiente).

Pendientes restantes:

  • Validación cross-field (ej: UUID del EntityRef existe en la entity referida).
  • Validación KCL del record post-edit antes de emitir Set/Clear.

feat(nakui-ui): snapshot/compaction durante runtime cada N writes

Cierra el último pending del round de persistencia. Antes el compact sólo corría al startup — para una sesión larga con muchas escrituras, el log crecía sin tope hasta el próximo restart, y el siguiente boot pagaba el costo lineal del replay.

Cambios:

  • Nuevos fields en MetaUi:
    • snap_path: PathBuf — cacheado del init para que el tick no tenga que recomputarlo.
    • snapshot_threshold: usize — leído del env en new() y cacheado. 0 desactiva runtime compact (mismo env y semantic que el threshold de startup).
    • writes_since_compact: u64 — contador que incrementa por cada write efectivo y se resetea cuando el threshold dispara maybe_compact_log.
  • Nuevo método tick_runtime_compact():
    • Early return si threshold == 0.
    • Increment + check vs threshold.
    • Si cruza: lock log + store, llama maybe_compact_log.
    • Si compactó OK: counter = 0, devuelve msg.
    • Si maybe_compact_log returned None (counter dijo "go" pero entries < 2): counter = 0 (no re-entrar cada write).
    • Si error: counter NO se resetea (próximo write reintenta), devuelve el error.
  • Nuevo helper append_compact_msg(base, opt): concatena el msg del compact al toast del op original con ";" separator.
  • Wireup en 3 callsites de write efectivo:
    • apply_action::SeedEntity: tick si outcome != NoChange.
    • apply_action::Morphism: tick siempre que Ok.
    • Click handler [Confirmar] del delete modal: tick si commit_delete Ok.
  • NoChange no cuenta: un edit que no cambia nada no escribe al log, así que tampoco debería avanzar el counter — preserva la semantic "1 write = 1 log entry = 1 tick".

2 tests nuevos:

  • append_compact_msg_handles_both_branches — base solo vs base
    • compact, formato del separator.
  • runtime_compact_cycle_resets_counter_after_threshold — E2E estilo simulación: 7 writes con threshold=3 → 2 compacts (en write 3 y 6), counter residual = 1, log final con 2 entries (1 anchor + 1 write residual). Reproduce el algoritmo del tick sin GPUI cx; si la lógica del método cambia, se rompe como signal.

37 tests verdes (+2). Workspace build verde.

Trade-offs:

  • Counter en memoria, no persistido: si la app crashea entre compacts, al próximo boot el counter parte de 0. El startup compact (basado en entry_count del log file) compensa esto: si quedó mucho post-último-compact, se compacta al boot.
  • Lock orden: tick toma log lock primero, store lock después. Misma orden que commit_seed y commit_morphism, no debería haber deadlock.
  • Costo del tick: 1 increment + 1 compare por write. Cuando cruza threshold, 1 read del log (entries) + 1 snapshot write + 1 compact. Para threshold=50 es ~1 fsync cada 50 writes — amortiza bien.

Pendientes restantes:

  • FieldOp::Clear — para soportar borrar un value vía form vacío.
  • Validación cross-field (UUID del EntityRef existe en la entity referida).

feat(nakui-ui): atajo Esc para cancelar el modal de delete

Cierra otro pendiente de UX. El banner de confirmación de delete ya tenía botones [Cancelar] / [Confirmar], pero la acción más natural para cancelar un dialog es Esc — y no la teníamos wireada.

Cambios:

  • capture_key_down en el root div del MetaUi::render. Capture phase (no bubble) para interceptar el Esc antes que cualquier TextInput descendiente lo consuma. Sin pending el handler es no-op y el evento sigue su flujo normal.
  • Match event.keystroke.key == "escape" + pending_delete.take() → toast "delete cancelado (Entity) [esc]" (sufijo [esc] para diferenciar visualmente del botón). Si no hay pending, return temprano sin tocar nada.
  • Hint visual en el banner: subtítulo en amber tenue debajo del título: "Esc para cancelar · click [Confirmar] para borrar". Que el usuario descubra el atajo sin RTFM.

35 tests verdes — el handler de Esc es 8 líneas no-testeables sin GPUI cx (la lógica de pending_delete + toast vive dentro del listener); el wireup compila por type-check.

Pendientes restantes:

  • FieldOp::Clear — para soportar borrar un value vía form vacío.
  • Snapshot durante runtime (cada N writes, no sólo al startup).
  • Validación cross-field (UUID del EntityRef existe en la entity referida).

feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)

Cierra otro pendiente: parse_field_value(FieldKind::EntityRef, raw) devolvía Ok(json!(raw)) blindly — el value entraba al log/store incluso si era basura. La validación de UUID sólo ocurría cuando el field se usaba como input del morphism (línea ~540 de commit_morphism); como seed field o como param, garbage pasaba silenciosamente.

Cambios:

  • parse_field_value(EntityRef, raw) ahora hace Uuid::parse_str(raw.trim()) y devuelve error claro si falla: "'<raw>' no es UUID válido (usá el selector de records)". En caso de éxito, devuelve el UUID trimmed como string — protege contra paste manual con whitespace.
  • Doble cobertura: este path cubre seed fields (commit_seed via obj.insert) y morphism params (resolve_param_value lo invoca por cada FieldSpec con kind=EntityRef). El path de morphism inputs ya validaba antes con Uuid::parse_str directo — sigue intacto, no hay double-validation.
  • Selector clickable es happy path: el dropdown setea valores bien-formados, así que el usuario nunca debería ver el error en uso normal. Sólo dispara con paste manual o si el usuario escribe garbage en el input — defensivo.

5 tests nuevos (reemplazan al obsoleto parse_field_entity_ref_returns_string):

  • parse_field_entity_ref_accepts_valid_uuid — happy path.
  • parse_field_entity_ref_trims_whitespace" uuid\n""uuid".
  • parse_field_entity_ref_rejects_non_uuid"abc-123" → error con el value y la palabra "UUID" en el mensaje.
  • parse_field_entity_ref_rejects_empty_string"" → rebota.
  • resolve_param_strict_entity_ref_propagates_error — sanity de que el wireup en resolve_param_value hereda el strict checking, con label del FieldSpec en el mensaje.

35 tests verdes (+4 net). El E2E del morphism real morphism_pipeline_executes_real_sales_vender sigue verde — sus inputs van por el path dedicado, no por parse_field_value.

Pendientes restantes:

  • Atajo Esc para Cancelar del modal de delete.
  • FieldOp::Clear — para soportar borrar un value vía form.
  • Snapshot durante runtime (cada N writes, no sólo al startup).
  • Validación cross-field (ej: el UUID del EntityRef existe en la entity referida) — hoy sólo validamos forma; un UUID válido pero inexistente sí pasa.

feat(nakui-ui): snapshot/compaction automático del event log al startup

Cierra el último gran pendiente del round: el replay full cada startup escala lineal en el log. Con 60+ entries el costo de boot se nota; con 10k entries es prohibitivo. Wireamos el snapshot machinery que ya estaba en nakui-core (Snapshot, replay_with_snapshot_into, EventLog::compact_through) al runtime de la UI.

Cambios:

  • Path del snapshot: sibling del log, extensión .snap.json. nakui-ui-state.jsonlnakui-ui-state.snap.json.
  • Nuevo helper snapshot_path_for(log_path) — derivación pura, testeable.
  • Nuevo helper maybe_compact_log(log, snap_path, store, threshold):
    • Si entry_count >= threshold y >= 2, captura Snapshot::from_memory_store(store, next_seq - 1), lo escribe atómicamente, y compacta el log dejando la última entry como anchor.
    • Anchor invariant: EventLog::open deriva next_seq del primer entry del archivo. Si compactáramos todo el log file, al reabrir el cursor volvería a 0 y el próximo append crashearía con NonMonotonic. Por eso compactamos sólo hasta next_seq - 2 — la entry del snap.seq queda como anchor del cursor; replay_with_snapshot_into la skipea porque snap ya cubre hasta ese seq inclusive.
    • Threshold via env NAKUI_SNAPSHOT_THRESHOLD, default 50. 0 desactiva por completo.
    • Devuelve Result<Option<msg>, String>: Ok(Some) si compactó, Ok(None) si no había payoff, Err si snap o compact fallaron.
  • MetaUi::new reescrito:
    • Carga snapshot al inicio (Some/None según exista).
    • replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) en lugar de replay_into.
    • Después del replay corre maybe_compact_log con el threshold.
    • Toast inicial menciona snapshot loaded si aplica ("snapshot @ seq K") y la compactación si ocurrió.
    • Errores de snapshot load no son fatales: cae a full replay con un msg en el banner.
    • Errores de auto-compact no son fatales: el log + snap quedan como estaban, msg al banner.

5 tests nuevos:

  • snapshot_path_for_replaces_extension.jsonl.snap.json, edge case sin extensión.
  • maybe_compact_log_below_threshold_noops — 5 entries vs threshold 50: no toca nada, no escribe snap.
  • maybe_compact_log_threshold_zero_noops — threshold 0 = disabled.
  • maybe_compact_log_then_reopen_preserves_records — E2E:
    • Escribe 60 seeds (log + store en sync).
    • Compacta (60 >= 50): snap escrito, log queda con 1 anchor entry, msg reporta "59 entries dropped (1 anchor kept)".
    • Reopen: next_seq=60 se preserva via anchor, entries.len()=1.
    • Replay con snap loadado en store fresco: los 60 records están.
    • Segunda corrida del compact con threshold=1: no-op (idempotente).

31 tests verdes (+5). Workspace build verde tras la nueva firma.

Trade-offs y notas:

  • Fail-soft: cualquier error de snap/compact no rompe el boot; la UI sigue funcionando con full replay y el toast lo reporta. Sólo EventLog::open failing es no-recoverable (pierde persistencia).
  • Crash-safety: WAL order preservado — escribimos snap (atómico via tempfile + fsync + rename) ANTES de compactar el log (atómico igual). Si crasheamos entre los dos, próximo boot ve snap@K + log con todas las entries 0..N — replay skippea las que snap cubre, outcome idéntico.
  • Sólo en startup: no hay snapshot durante runtime. Para sesiones largas con muchas escrituras, el log puede crecer arbitrariamente hasta el próximo restart. Pendiente futuro: snapshot N writes desde el último compact.
  • Anchor entry sobrevive sin uso útil: el costo es 1 línea JSON por compact. No es preocupación a menos que el threshold sea muy chico (cada compact deja 1 línea de basura).

Pendientes restantes:

  • EntityRef validation post-submit — validar UUID parseable al submit en lugar de al execute del morphism.
  • Atajo Esc para Cancelar del modal de delete.
  • FieldOp::Clear — para soportar borrar un value vía form vacío.
  • Snapshot durante runtime (cada N writes, no sólo al startup).

feat(nakui-ui): edit delta-only — sólo campos modificados al log/store

Antes de este cambio, editar un record emitía un FieldOp::Set por cada field del form, incluso los no tocados. Eso bloata el log (replay tenía que aplicar N ops cuando 1 alcanzaba) y oscurece el intent en una auditoría posterior. Con delta-only, el edit emite sólo los Sets cuyo value nuevo difiere del actual; un edit que no cambia nada deja el log intacto.

Cambios:

  • Nuevo helper compute_field_delta(current, proposed) — toma el record actual del store (un Value, posible Null si el record no existe) y el Map propuesto desde el form, y devuelve sólo las entries que difieren. Comparación: PartialEq estructural de serde_json::Value (un Null en current = todos los proposed son nuevos).
  • Nuevo enum CommitOutcome:
    • Created(Uuid) — alta nueva.
    • Updated { id, changed } — edit con N campos modificados.
    • NoChange(Uuid) — edit sin diferencias (el toast lo refleja como "X sin cambios — no log entry").
  • commit_seed en path EDIT:
    • Carga current via store.load(entity, id) con fallback a Value::Null.
    • Calcula delta. Si vacío → return early sin tocar log ni store.
    • Si no vacío → emite Morphism { ui.edit_record, ops: [Set...] } con params.fields reflejando el delta (no todo el form), haciendo la auditoría grep-able por field cambiado.
  • Toast del callsite:
    • creado X uuid (Created)
    • actualizado X uuid (N campo(s)) (Updated)
    • X uuid sin cambios — no log entry (NoChange)
  • editing se limpia incluso en NoChange — el modo edit cierra, el form vuelve al state limpio.

5 tests nuevos del helper:

  • delta vacío cuando todo coincide.
  • delta sólo con el field cambiado.
  • delta full cuando current = Null (record no existe).
  • distingue int 100 de string "100".
  • ignora fields del current que no están en proposed.

27 tests verdes (+5). El path SEED no cambió; el E2E del morphism real sigue verde.

Limitación conocida (consistente con pre-delta): el form no puede borrar un value vaciando el input — empty optional fields hacen continue antes de llegar al delta. Para clearear un value hay que declarar el field como required, o esperar a un FieldOp::Clear futuro (no necesario hoy: ningún demo lo requiere).

Pendientes restantes:

  • Snapshot/compaction del log (replay full cada startup escala mal con repos grandes).
  • EntityRef validation post-submit — validar UUID parseable al submit en lugar de al execute del morphism.
  • Atajo Esc para Cancelar del modal de delete.
  • FieldOp::Clear — para soportar borrar un value vía form.

feat(nakui-ui): confirmación de delete vía banner modal antes de borrar

Cierra el primer pending del último round: borrar un record pedía un solo click en y se ejecutaba inmediatamente (irreversible — queda en el log como Morphism { name: "ui.delete_record" }). Ahora hay un paso intermedio: el click marca el record como pendiente y el banner amber al tope de la ventana ofrece [Cancelar] o [Confirmar].

Cambios:

  • Nuevo state MetaUi.pending_delete: Option<(String, Uuid)>. Set en el click del ; limpiado por:
    • [Cancelar] → toast "delete cancelado (Entity)".
    • [Confirmar] → llama commit_delete (igual que antes) y emite el toast usual.
    • Navegación a otra view (select_view) — el record marcado podría no estar visible en la nueva pantalla.
  • Click handler de ya no llama commit_delete: sólo setea pending_delete y limpia toast. La acción destructiva ahora vive exclusivamente en el botón [Confirmar] del banner.
  • Nuevo método render_confirm_delete_banner: devuelve Option<Div> (None si no hay pending). Banner amber con el texto ¿Borrar {Entity} {short_uuid}? + dos botones. Renderea como sibling del row sidebar+main en flex_col raíz — no es overlay flotante (GPUI no expone z-index trivialmente), pero la posición fija al tope + color amber lo hacen imposible de ignorar.
  • Limpieza pre-commit: pending_delete = None se ejecuta antes de commit_delete, así un fallo del commit no deja el banner colgado además del toast de error.

22 tests verdes — la lógica del store/log no cambió, sólo el state machine de UI. La confirmación es puramente UX/state, no testable sin GPUI cx, pero la compilación garantiza wireup correcto de las closures.

Pendientes restantes:

  • Snapshot/compaction del log para repos grandes (replay full cada startup escala mal).
  • Edit delta-only — sólo campos modificados, no todos.
  • EntityRef validation post-submit — validar UUID parseable al submit en lugar de al execute del morphism.
  • Atajo de teclado Esc para Cancelar — requiere event dispatcher de GPUI, fuera de scope inmediato.

feat(nakui-ui): validación estricta de params del morphism vía FieldKind del FieldSpec

Cierra el último trade-off documentado: infer_param_value adivinaba el tipo de cada param por la shape del string (i64 → f64 → bool → string). Ahora cuando hay FieldSpec declarado, usamos parse_field_value(spec.kind, raw) — un Boolean field con value "abc" rebota con mensaje claro en la UI antes de llegar al morphism Rhai (donde el error sería opaco como "Function not found: * ((), ())").

Cambios:

  • Nuevo helper resolve_param_value(field_name, raw, spec):
    • Si hay FieldSpec: validación de required (rebota empty con "param 'X' es obligatorio y está vacío") + parseo estricto via parse_field_value(spec.kind, raw). Errores incluyen el label del spec para que el toast sea interpretable.
    • Si NO hay spec (param declarado en Action::Morphism.params que no existe en form.fields — módulo mal-formado): fallback a infer_param_value como red de seguridad.
    • Empty + opcional → Value::Null.
  • commit_morphism simplificado: el loop de params ahora es 3 líneas (lookup spec + llamada a resolve_param_value + inserción al map). La lógica vive en el helper standalone, testable sin GPUI.

Tests: 6 nuevos en tests mod, todos contra resolve_param_value:

  • resolve_param_strict_number_parses_i64 — happy path.
  • resolve_param_strict_boolean_rejects_non_boolean — un Boolean con "abc" rebota con mensaje que incluye el label.
  • resolve_param_strict_number_rejects_garbage — Number con "abc" rebota.
  • resolve_param_required_empty_rejected — required vacío rebota con "obligatorio".
  • resolve_param_optional_empty_returns_null — optional vacío → null.
  • resolve_param_no_spec_falls_back_to_infer — el fallback preserva el comportamiento anterior para back-compat.

22 tests verdes en nakui-ui (+6). E2E del morphism real (morphism_pipeline_executes_real_sales_vender) sigue verde — la validación estricta no rompe el path correcto, sólo agrega rebotes tempranos a values mal-tipados.

Beneficio operativo:

  • Mensaje de error en la UI ahora identifica el field problemático por su label legible ("param 'Cantidad': 'abc' no es número") en lugar del error opaco del morphism Rhai.
  • Errores se ven antes de tocar el log o el store — ningún cambio parcial.
  • El módulo Nakui ya no tiene que defender contra inputs garbage desde la UI: la metainterfaz se vuelve la primera línea de validación tipada.

Pendientes futuros (orden de prioridad):

  • Confirmación de delete — modal antes de borrar.
  • Snapshot/compaction del log para repos grandes.
  • Edit delta-only — sólo campos modificados, no todos.
  • EntityRef validation post-submit: hoy parse_field_value para EntityRef devuelve string raw; el commit_morphism luego valida como Uuid sólo cuando es input del morphism. Para EntityRef como param, podríamos validar UUID al submit.

feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes

Cierra el principal trade-off documentado del commit anterior: "Inputs UUID a mano (no dropdown)". Los formularios pueden declarar un campo entity_ref que apunta a una entity y el runtime renderea una lista clickable de records existentes; click selecciona, el UUID queda guardado para el submit.

Schema nakui-ui-schema:

  • Nueva variante FieldKind::EntityRef (serializa como "entity_ref" en JSON).
  • FieldSpec.ref_entity: Option<String> nuevo. Indica qué entity ofrecer en el selector. validate() chequea que cualquier field con kind=entity_ref tenga ref_entity set.
  • Nuevo error tipado SchemaError::EntityRefMissingTarget.

Runtime nakui-ui:

  • render_entity_ref_selector(field_name, target_entity, ...) — helper que arma la lista debajo del input. Cada item:
    • Etiqueta humana via human_label_for_record (heurística: namelabeltitleskusku_id → fallback al UUID corto).
    • Click handler vía cx.listener que llama input.set_text(uuid_completo) — el TextInput interno queda como source-of-truth, así que commit_seed y commit_morphism leen el UUID seleccionado sin saber que vino de un selector.
    • Highlight en accent color cuando el item es el actualmente seleccionado (compara contra el contenido del TextInput).
  • parse_field_value(EntityRef, raw) devuelve string del raw (la validación como Uuid ocurre downstream en commit_morphism).
  • Mensaje "(sin {entity}: creá uno antes para referenciar)" cuando la lista está vacía — el user sabe qué hacer en lugar de quedarse trabado.

Demo actualizado: examples/nakui-modules/sales_engine/module.json:

  • vender_form.fields.stock_id_input y caja_id_input cambian de kind: "text" a kind: "entity_ref" con ref_entity: "Stock" y "Caja" respectivamente.
  • Ahora el flujo "Vender" es: (1) click en una Stock listada bajo el input, (2) click en una Caja, (3) escribir venta_id/cantidad/ precio_unitario/timestamp, (4) submit. Sin copiar UUIDs.

Tests:

  • 2 nuevos en schema: validate_catches_entity_ref_without_target y entity_ref_with_target_validates_clean. 8 totales.
  • 4 nuevos en runtime: parse_field_entity_ref_returns_string, human_label_for_record_prefers_name_over_id, human_label_falls_back_through_label_title_sku, human_label_falls_back_to_id_when_no_known_keys. 16 totales.
  • Integration de los 7 demos sigue verde — el demo sales_engine ahora valida con EntityRef + ref_entity correctamente set.

29 tests totales nakui-ui + schema, 100% verde. El demo sales_engine carga limpio con la nueva forma del schema.

Pendientes futuros:

  • Confirmación de delete — modal antes de borrar.
  • Snapshot/compaction del log para repos grandes.
  • Edit delta-only (sólo campos modificados).
  • Validación de tipos en params del morphism: FieldKind declarado en el FieldSpec se podría usar para forzar parseo estricto en commit_morphism en lugar de la heurística infer_param_value.

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:

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); el form view se reusa para alta y para edit; el delete es inline. Las mutaciones pasan por LogEntry::Morphism con sus ops, así el replay restaura el estado correcto.

Cambios:

  • MetaUi.editing: Option<(String, Uuid)> nuevo. Set al click en ✎; cleared al cambiar de view o tras submit exitoso.
  • open_edit(mod_idx, entity, id, cx): setea editing, busca la primera Form view del módulo cuya entity matchee, navega ahí. Si el módulo no tiene Form para esa entity → toast con error ("no hay form view para entity X").
  • select_view extendido: cuando carga un Form, si editing matchea esa entity y el record existe en el store, pre-llena cada input con el valor del record (vía nuevo helper value_to_input_text — inverso de parse_field_value).
  • commit_seed ramifica:
    • Edit path (cuando editing.is_some() y entity matchea): emite LogEntry::Morphism { name: "ui.edit_record", ops: [Set { path, value } for each field], params: { entity, id, fields } }. Aplica al store via apply(&ops).
    • Seed path (alta nueva): comportamiento previo.
  • commit_delete(entity, id): emite LogEntry::Morphism { name: "ui.delete_record", ops: [Delete { entity, id }] } + apply.
  • Render del form: título cambia a "Editar customer abc12345" cuando editing matchea; submit label cambia a "Guardar cambios en customer".
  • Render de la lista: dos columnas nuevas — "id" y "acciones". Cada fila tiene ✎ (accent color, click → open_edit) y ✕ (rojo, click → commit_delete). Hover states.

Ramificación visible en el event log:

{"kind":"seed","seq":0,"entity":"customer","id":"abc...","data":{"name":"Acme"}}
{"kind":"morphism","seq":1,"morphism":"ui.edit_record","ops":[
  {"op":"set","path":{"entity":"customer","id":"abc...","field":"name"},
   "value":"Acme S.A."}
]}
{"kind":"morphism","seq":2,"morphism":"ui.delete_record","ops":[
  {"op":"delete","entity":"customer","id":"abc..."}
]}

Coherente con el modelo de Nakui — todo cambio post-seed pasa por ops dentro de Morphism. nakui-explorer muestra estos morphisms con sus ops claros en su timeline.

Trade-offs documentados:

  • schema_hash: None sigue para los morphism de la UI (legacy/ pre-versioning path) hasta que Action::Morphism cargue Manifest schemas.
  • Delete sin confirmación: 1 click, sin modal. Para MVP es OK (los records son recuperables vía replay parcial), pero un futuro iter agregaría confirmación.
  • Edit sobreescribe TODOS los campos del form, no sólo los cambiados — emite N ops Set, una por field. Adecuado para forms chicos; para forms con muchos campos optimizar a delta-only.

Tests: 3 nuevos (10 totales en nakui-ui):

  • value_to_input_text_inverse_of_parse y value_to_input_then_parse_round_trip — la propiedad fundamental del pre-llenado: text → parse devuelve el Value original.
  • event_log_replay_handles_full_crud_cycle — E2E del log: escribe Seed + Morphism(Set ops) + Morphism(Delete op), replay desde cero, verifica que el store termina vacío (delete fue el último). Verifica además que un replay parcial (sin el delete) deja los valores editados.

Activación:

NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
NAKUI_MODULES_DIR=examples/nakui-modules \
cargo run -p nakui-ui
# Crear un customer, click ✎ en su fila, modificar campos,
# "Guardar cambios". Click ✕ en otra fila para borrar.
# Cerrar y reabrir: el state persiste con todos los cambios.

feat(nakui-ui): persistencia con event log + replay al startup

Cierra "sin persistencia entre runs" del commit anterior. Cada SeedEntity se appendea al nakui_core::event_log::EventLog con WAL semantics (log antes que store) y al re-abrir el binario el replay reconstruye el MemoryStore desde cero. Cerrar y volver a abrir ya no borra el data.

Cambios:

  • MetaUi.event_log: Option<Arc<Mutex<EventLog>>> nuevo. Compartido bajo Mutex para que el commit_seed pueda mutar.
  • Apertura + replay al startup (MetaUi::new): path por env NAKUI_EVENT_LOG, default ./nakui-ui-state.jsonl. EventLog::open + replay_into reconstruyen el store. Toast informativo: "log nuevo" o "log X cargado: N evento(s) replayed".
  • WAL en commit_seed: si event_log.is_some(), primero log.append(LogEntry::Seed { ..., schema_hash: None }), después store.seed. Si el append falla, cancela toda la operación (el user reintenta sin haber dejado state inconsistente).
  • schema_hash: None: documentado como path "legacy / pre-versioning" para seeds que no pasan por un Manifest+Executor. Es el path correcto para alta administrativa vía la metainterfaz hasta que Action::Morphism wireé el Manifest loader.
  • Degradación grácil: si abrir log falla (permisos, disco), toast con error pero el runtime sigue en modo in-memory.

Tests: 1 nuevo E2E event_log_replay_restores_memory_store que escribe 2 seeds via EventLog::append, re-abre y replay_into un store fresh, verifica que ambos records están con sus values correctos. Reproduce el flujo del startup de MetaUi::new sin necesitar GPUI. 7 tests verdes en nakui-ui.

Activación con persistencia explícita:

NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
NAKUI_MODULES_DIR=examples/nakui-modules \\
cargo run -p nakui-ui
# Crear varios records vía el form, cerrar el binario, abrir de
# nuevo: los records están.

Limitaciones que siguen (próximos iters):

  • Action::Morphism sigue como TODO: requiere cargar el Manifest de nakui-core junto al Module UI para conocer los inputs/params declarados y poder llamar execute_and_log.
  • No hay snapshot/compaction: el log crece append-only para siempre. Para repos grandes habría que integrar Snapshot de nakui_core (existe, no se usa todavía).
  • No hay UI para borrar/editar records existentes — sólo alta vía form. Edit + delete en futuras iteraciones.
  • Widget input simple (sin selection/IME/clipboard) — heredado de la limitación documentada de yahweh-widget-text-input.

feat(nakui-ui): inputs reales con yahweh-widget-text-input + click handlers funcionales

Cierra dos limitaciones documentadas en el commit anterior de la metainterfaz: los formularios ahora aceptan teclado real, y los clicks en menús + botones mutan estado correctamente.

Cambios:

  • Inputs vivos: cada FieldSpec del Form view materializa un Entity<TextInput> (de yahweh-widget-text-input) al entrar a la vista. Los entities se reemplazan al cambiar de view (drop limpio). El widget soporta: escribir caracteres, Backspace, Enter (Confirmed event — no usado todavía; el submit va por botón), Escape (Cancelled). El cursor se renderea como | al final.
  • Click handlers wired vía cx.listener: menús del sidebar invocan select_view; botones de acción (header de list, submit de form) invocan apply_action. Los handlers tienen acceso real al Context<MetaUi> y mutan el modelo + emiten cx.notify().
  • Submit lee texto de los inputs: commit_seed reemplaza el buffer ad-hoc anterior por input.read(cx).text() por cada field. El value parseado va al MemoryStore con su tipo correcto (text/number/boolean/date).
  • Reset de inputs tras submit: si la acción no tiene next_view, los inputs se vacían (set_text("")) para alta consecutiva sin re-tipear.
  • Hover states: items del sidebar y botones cambian de bg al pasar el mouse, feedback visual consistente con el resto del ecosistema yahweh.
  • Theme global: Theme::install_default(cx) al inicio (lo requiere el text_input para sus colores).

Wire en Cargo:

  • Deps nuevas: yahweh-widget-text-input, yahweh-theme (paths relativos al monorepo).

Limitaciones que siguen abiertas (próximos iters):

  • Action::Morphism sigue como TODO: requiere cargar el Manifest de nakui-core junto al Module UI para conocer los inputs/params declarados.
  • Sin persistencia entre runs: MemoryStore en RAM. Wire con EventLog o SurrealStore queda para cuando exista el daemon Nakui.
  • Inputs simples: el widget no soporta cursor positioning, selection, copy/paste, IME, multilínea. Para edits serios habrá que portar gpui::examples::input o adoptar gpui-input cuando exista upstream.
  • Enter no envía: el TextInputEvent::Confirmed que emite el widget no está suscrito todavía; el submit va por click. Trivial de wirear si lo necesitamos.

Tests: los 6 unit del runtime siguen verdes (parse_field_value para los 5 kinds, lookup_field nested, render_value). El comportamiento visual requiere correr el binario con cargo run -p nakui-ui y probar a mano — GPUI no provee harness de UI testing en CI hoy.

Activación full:

NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
# Click en un menú → carga vista. Click en "Nuevo" → form.
# Tipear en cada campo → ver el `|` al final. Click "Crear customer"
# → record aparece en la lista.

feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar

Salto cualitativo: Nakui pasa de "library + demos + read-only viewer del event log" a plataforma ERP con UI dirigida por datos. Cada módulo de negocio se declara como un module.json (sin código Rust nuevo) y el runtime GPUI lo carga dinámicamente: sidebar de menús, listas con columnas configurables, formularios de alta.

Tres entregables:

1) Crate nuevo nakui-ui-schema (datos puros, ~250 LOC + 200 LOC tests):

  • Module { id, label, entities, menu, views }.
  • View::List { entity, columns, actions, search_in } o View::Form { entity, fields, on_submit }.
  • FieldSpec { name, label, kind, default, required, help } con FieldKind = Text|Multiline|Number|Boolean|Date.
  • Action::OpenView | SeedEntity | Morphism — el runtime las dispara desde botones / submits.
  • Module::from_path parsea un JSON; Module::validate chequea que cada MenuItem.view exista en views.
  • load_modules_from_dir(dir) busca dir/<modulo>/module.json, parsea, valida, detecta IDs duplicados, devuelve ordenado.
  • 6 tests unit + 4 integration (los 6 demos cargan limpio, todos tienen list+form, kinds reconocidos, validate pasa).

2) Crate nuevo nakui-ui (binario GPUI, ~700 LOC + 100 LOC tests):

  • Carga módulos desde NAKUI_MODULES_DIR (default ./nakui-modules).
  • Sidebar con módulos + sus menús; click en menu cambia la vista activa.
  • List view: tabla de instancias del entity con columnas weighted (header de columnas + filas + id corto).
  • Form view: campos labeled + botón submit que dispara la action declarada (SeedEntity mete el record al MemoryStore in-process; Morphism queda como TODO hasta integrar el manifest loader nakui-core).
  • MemoryStore compartido entre todas las vistas (Arc); el cambio en un módulo se refleja en otro inmediato.
  • Toast + error banner para feedback.
  • 6 tests unit (parse_field_value para los 5 kinds, lookup_field nested, render_value).

3) 6 módulos demo en examples/nakui-modules/ que cubren un ERP estándar:

  • customers: nombre, email, teléfono, activo, límite de crédito, notas.
  • products: SKU, nombre, categoría, precio, stock, activo.
  • suppliers: razón social, ID fiscal, contacto, email, teléfono, términos de pago.
  • inventory_movements: fecha, tipo (in/out/adjustment), SKU producto, cantidad, costo unitario, motivo, doc. referencia.
  • sales_orders: número, cliente, emisión, vencimiento, estado, subtotal, impuestos, total, notas.
  • invoices: número, cliente, emisión, vencimiento, subtotal, impuestos, total, pagado, estado, moneda, orden referenciada.

Cada módulo tiene su list (catálogo) + form (alta), con search field y columns weighted. Los 6 cubren un setup de ERP de ventas chico funcional para demo.

Filosofía documentada:

  • UI como datos: agregar un módulo = escribir un JSON, no recompilar el binario.
  • Persistencia universal: el runtime conecta cada vista al nakui_core::store::Store; cambiar de MemoryStore a SurrealStore no toca los module.json.
  • Schema primero, semántica después: nakui-ui-schema sólo define la forma; validación de referencias rotas (entity inexistente, morphism faltante) vive en el runtime.

Activación:

NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui

Limitaciones conocidas (próximas iteraciones):

  • Inputs sin teclado: GPUI no incluye text input; los forms muestran los default del schema y el submit usa esos. Próximo iter: integración con yahweh-widget-text-input.
  • Click handlers no wired: GPUI necesita pasar Entity<MetaUi> a los handlers para mutar estado; refactor con cx.listener + weak refs queda para el próximo iter. Hoy la navegación es visual; el código de mutación sí funciona via API programática (los tests lo cubren).
  • Acción Morphism: pendiente de cargar el Manifest de nakui-core junto con el Module UI para wirear execute_and_log.
  • Sin persistencia entre runs: el MemoryStore se pierde al cerrar. Wire con EventLog o SurrealStore queda para cuando el daemon Nakui exista.

Tests: 16 totales nuevos (10 schema + 6 runtime). 100% verde.

Lo que esto desbloquea: cualquiera puede escribir un module.json para su dominio (pacientes médicos, alumnos de escuela, reservaciones de hotel) y aparece en la UI sin tocar Rust ni recompilar. La forma de extender Nakui dejó de ser "agregar código al ERP" y pasó a ser "escribir el contrato del módulo".

feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz

Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone nakui-explorer (paralelo a nouser-explorer) que renderea el event log de un repo Nakui: timeline scrollable de seeds + morphisms con sus parámetros, breakdown por entity type, polling cada 2s para detectar nuevos eventos appended sin restart del explorer.

Diseño:

  • Lee directamente el archivo .jsonl del nakui_core::event_log::EventLog. Path por env NAKUI_EVENT_LOG, default nakui.jsonl en pwd.
  • Sin discovery vía broker brahman porque nakui hoy es CLI/library/ demos, no daemon. Cuando se daemonice, sustituir el lector de archivo por un sidecar consumer (mismo patrón que nouser-explorer actualmente usa).

UI:

  • Header: path del log, count total + breakdown seeds/morphisms, tiempo del último reload en ms.
  • Breakdown line: top 5 buckets por frecuencia (entities + nombres de morphisms con prefijo ).
  • Timeline: tarjetas color-coded por kind (azul=seed, verde=morphism). Cada tarjeta muestra #seq, kind, entity/morphism name, id corto (8 hex), preview del data/params (80 chars), schema hash corto (8 hex) o (legacy) si pre-versioning. Mostradas más-recientes-primero, hasta 200 visibles (suficiente para navegación; sin scroll virtualizado por ahora).
  • Error banner: si la lectura falla (archivo inexistente o corrupto), banner rojo con el motivo. El explorer NO crashea — sigue intentando cada 2s.

Wire en workspace:

  • Nuevo crates/apps/nakui-explorer/ agregado a [workspace] members.
  • Deps mínimas: nakui-core (para EventLog + LogEntry), gpui, serde_json, uuid (con feature serde para parsear los IDs).
  • Sin deps de brahman por ahora (Nakui standalone).

Tests: 7 unitarios en tests mod del bin:

  • load_log_returns_all_entries_in_order — cargar un .jsonl generado a mano, asserta que devuelve 5 entries con seqs 0..4 contiguous.
  • breakdown_counts_seeds_morphisms_and_buckets — verifica el conteo (3 seeds + 2 morphisms) y los buckets esperados.
  • load_missing_file_yields_empty_not_error — archivo inexistente devuelve [] sin error (delegado al contrato de EventLog::open).
  • preview_value_truncates_long_strings y _keeps_short_strings_intact.
  • short_uuid_takes_first_8_chars y short_hash_takes_first_4_bytes_hex.

Activación:

NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer

Estado del CHANGELOG global tras este commit: cero pendientes fundamentados activos. Lo único que queda es minga-vfs (FUSE, explícitamente diferido por el usuario) y mejoras nice-to-have (cobertura adicional per-lenguaje, daemon-ización de nakui para sidecar discovery).

chore(nakui): alinear nakui-core con [workspace.package] y deps compartidas

Cleanup de drift de convenciones: nakui-core era el único crate del monorepo que mantenía version = "0.1.0" / edition = "2021" / thiserror = "1" hardcoded, mientras el resto heredaba del workspace y usaba thiserror = "2". Eso significaba que un bump global de versión o de edition se olvidaba sistemáticamente de nakui.

Cambios:

  • [package]: version, edition, rust-version, license, authors, publish → todos *.workspace = true. Agregado description (cumple convención del resto de crates).
  • Deps compartidas migradas a { workspace = true }: serde, serde_json, thiserror (v1→v2), tokio, ulid, sha2.
  • uuid migrado a { workspace = true, features = ["serde"] } — la feature serde no está en el workspace dep porque nakui es el único user; queda local opt-in en lugar de inflar el dep común.
  • Deps específicas de nakui (sin compartición posible): rhai, petgraph, surrealdb permanecen inline con versión local.

Verificación: cargo build -p nakui-core verde tras el bump de thiserror v1→v2 — el #[derive(Error)] de los 14+ enums de error en nakui no requirió ajustes (la API de derive es backwards-compatible para los patrones simples). cargo test -p nakui-core --lib: 27/27 verdes, sin regresión.